Skip to content

fix(lint): null-guard gate accuracy follow-ups: the runtime door's bare start-guard gap recorded, the summary-nulls presumption stated, an insert-safe guard for previous - #29582

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

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-u2dp08

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Warren · project thread
Fixes #29491

Clause-②: no
Metadata-protocol: none

Dispatch: Claim 6586327517 · Thread-read 6584817890 · Source: contract review 6582253324 on PR #29474

维护者速读

  • 改了什么:null-guard 门禁三处准确性修正:记下运行时发布门对「start 条件里的裸名守卫」比 os validate 更严;roll-up 汇总恒有值的判断写明以跑过 os migrate summary-nulls 为前提;previous.x 的报错补一句插入时也成立的完整守卫写法。
  • 为什么改:合约复核 6582253324 的三条 should-fix:一个流在构建门过、发布门被拒却无处记载;注释把一次性迁移说成自动回填;previous.x != null 在插入时本身就会出错,旧提示只给了半个守卫。
  • 风险与回滚:拒收哪些谓词完全不变,只改一条报错的结尾文字、两处注释和 changeset 文字;回滚即 revert 本 PR。待发布的 29026 changeset 有两句被刻意更正(原文与新文见 Acceptance notes),所以 Check Changeset 预期会红。
  • 先看:packages/lint/src/validate-null-guards.ts 的 nullGuardMessage 结尾与 PREVIOUS_GUARD_CLAUSE;再看 .changeset/29026-flow-condition-null-guard.md 的两句改动;最后 validate-expressions.flow-null-guards.test.ts 新增的两门对照用例。
  • 两分钟验证:pnpm --filter @objectstack/lint exec vitest run src/validate-null-guards.test.ts src/validate-expressions.flow-null-guards.test.ts,预期 46 个用例全过。
  • 需要你做的:无,席位落地。

What changes

  1. The runtime-door gap is recorded, not narrowed (the order's default arm). At os validate, where a record-triggered flow's bound set is provable, a bare start condition budget != null proves record.budget, so an edge comparing record.budget > 200000 passes. The runtime publish gate judges the written flow alone (perWriteSnapshotEntrance), its bound set is not provable, no bare name is judged, the bare guard proves nothing, and the same edge is refused. That stricter side stands; the ledger now says so and says why:
    • validate-null-guards.ts, surface ledger flow row: the verdict cell gains "the runtime publish gate is stricter on a bare start guard; see below", and wiring point 3 ("The start condition gates every run") gains the paragraph naming the two doors, the reason deriving the presumption there would be unsound, the cure (record.budget != null in the start condition, accepted at both doors), and the test that pins it. No runtime stand-down was taken: nobody measured one sound.
    • .changeset/29026-flow-condition-null-guard.md L15, the runtime-gate sentence (deliberate correction, quoted below).
  2. isAlwaysValuedSummary no longer says the engine backfills. validate-expressions.ts doc comment: the engine seeds a new parent at create time (initializeSummaryFields) and falls back on a child write's recompute; nothing backfills a stored NULL on its own. The count/sum judgment presumes the one-off os migrate summary-nulls (summary-backfill.ts) ran; on a database upgraded in place without it, such a parent reads null and a validation rule that this gate passed refuses the write. The 29026 changeset's roll-up sentence (L17) says the same (deliberate correction, quoted below).
  3. A previous. operand's message names the guard that holds on an insert. nullGuardMessage appends PREVIOUS_GUARD_CLAUSE after the shared hint, only for an operand spelled previous.FIELD: "On an insert previous is itself null, so previous.budget != null alone faults there too: guard it with previous != null && previous.budget != null." NULL_GUARD_HINT is untouched, so it still reads word for word as objectql's rule-validator sentence. The clause applies on every surface: a validation rule and a hook condition bind previous to null on an insert (previousBinding), as a flow's create leg does (seedRunVariables). The ledger's "Not reached" paragraph points at it. New patch changeset .changeset/29491-previous-guard-hint.md.

Tests

  • validate-null-guards.test.ts, new case: for previous.budget > 5, under both fail-closed and run-fails, the message carries the verbatim hint followed by the clause naming previous != null && previous.budget != null; a record.budget operand and a bare budget operand get no clause; the prescribed guard passes the gate; and evaluated with previous bound to null (as on an insert) the full guard answers false while previous.budget != null && … alone faults.
  • validate-expressions.flow-null-guards.test.ts, new case: a start condition budget != null with an edge record.budget > 200000 gives no finding through validateStackExpressions (build door) and exactly one, on edge 'e2', through runStackExpressionPasses(…, { runtimeWriteType: 'flow' }) (runtime door); the record.budget != null spelling gives none at either door.
  • Ablation (committed fix first, scripts/ablation-replace.mjs, trap-restored): replacing the clause expression with an empty string turned the new message case red (Tests 1 failed / 33 passed); restore proved by blob equal to HEAD and an empty git diff HEAD.
  • pnpm --filter @objectstack/lint test at d4f21b7: Test Files 192 passed (192), Tests 7882 passed, 5 skipped. pnpm --filter @objectstack/lint typecheck (tsc, check:test-typecheck): exit 0.

Acceptance notes

  • Deliberate correction of the pending changeset .changeset/29026-flow-condition-null-guard.md (not yet released). Check Changeset is expected to read red on an edited existing changeset; that is the gate doing its job, and this paragraph is the confirmation.
    • L15 before: "At the runtime publish gate bare names are not judged."
    • L15 after: "At the runtime publish gate bare names are not judged, so a bare start-condition guard proves nothing there: a flow whose start condition is budget != null and whose edge compares record.budget > 200000 passes os validate (where the flow's bound set is provable) and is refused by the runtime publish gate. Spell the start guard record.budget != null and both accept it."
    • L17 before: "A count or sum roll-up summary is now always valued for this gate (zero children is zero), on validation rules and hook conditions too; min / max / avg roll-ups stay nullable."
    • L17 after: the same sentence, then "That presumes os migrate summary-nulls ran: on a database upgraded in place without it, a parent row stored before roll-ups were seeded at create time, and no child of it written since, still reads null."
  • Which predicates either door refuses does not change. Item 1 took the record arm, so Clause-②: no and no contract review is owed by this PR.
  • PREVIOUS_GUARD_CLAUSE is module-private; the package's export list is unchanged.

Verification

dispatch-gates at d4f21b7: 58 derived, 57 exit 0 or the expected red, 1 NOT MEASURED

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 58 families against merge base ff827bc. Prerequisites first: git fetch origin 621a4876 (plugin-teardown self-test) and turbo run build --filter='@objectstack/lint...' --filter=@objectstack/objectql (exit 0). Then every family ran once from a runner recording CMD :: exit N:

  • 56 exited 0.
  • node scripts/check-empty-changeset.mjs --base origin/main exited 1 on .changeset/29026-flow-condition-null-guard.md, naming the DELIBERATE CORRECTION class. Expected; see Acceptance notes. The 29491 changeset is new and not flagged.
  • pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: it needs every package built). NOT MEASURED locally; CI runs it.

Reconciliation: "dispatch-gates --ran: 58 derived famil(ies) accounted for — 57 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)".

origin/main moved six commits past the merge base while the gates ran (head ebbb5b8 and later); none touches packages/lint, packages/formula, packages/objectql or the 29026 changeset, and git merge-tree against it is clean. Not merged in; CI judges the merge ref.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (not touched)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (CI owns the repo-wide lint run)
  • 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 touched: its generated artifacts are regenerated and committed. (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 maintainer digest the dispatch order requires)
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…n review

- Record that the runtime publish gate refuses an edge `os validate`
  accepts when the start condition guards a BARE name: the ledger's flow
  row and the 29026 changeset's runtime-gate sentence now say so, and a
  test pins both doors.
- `isAlwaysValuedSummary`: the count/sum judgment presumes the one-off
  `os migrate summary-nulls` ran; nothing backfills a stored NULL on its
  own. The 29026 changeset's roll-up sentence says the same.
- A `previous.` operand's message now names the guard that holds on an
  insert, `previous != null && previous.x != null`, after the verbatim
  shared hint, which is unchanged.

Claude-Session: https://claude.ai/code/session_01JQwxbZNEUhFEaL9RRQWJRR
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m 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

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/validate-expressions.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/validate-expressions.ts) — pages documenting those are invisible to this run
  • 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 e832ddb1f5a1cd9804ec9de998a9c1b8cd35b781 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json e832ddb1f5a1cd9804ec9de998a9c1b8cd35b781

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · os-warren · session_01JQwxbaBjisxkQo9hht9bFB

Contract-review-claim: session_01JQwxbaBjisxkQo9hht9bFB · head d4f21b7 · 2026-11-02T20:00Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · os-warren · session_01JQwxbaBjisxkQo9hht9bFB

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d4f21b7167b4a377cb7d339f4a85edeee397cc93
Local-runs: none
Scope: .changeset/29026-flow-condition-null-guard.md, .changeset/29491-previous-guard-hint.md, 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.flow-null-guards.test.ts + packages/lint/src/flow-cel-root-scope.ts (flowCelRootScope, perWriteSnapshotEntrance), packages/lint/src/authoring-rules.ts (the runtime door's runStackExpressionPasses(stack, { runtimeWriteType }) call), packages/objectql/src/validation/rule-validator.ts (previousBinding, unevaluableRuleError), packages/objectql/src/cel-fault.ts (the runtime null-overload sentence), packages/objectql/src/hook-wrappers.ts (pickPreviousPayload), packages/objectql/src/summary-aggregate.ts (summaryEmptySetValue, aggregateSummaryValue), packages/objectql/src/engine.ts (initializeSummaryFields), packages/objectql/src/summary-backfill.ts, packages/cli/src/commands/migrate/summary-nulls.ts, packages/services/service-automation/src/dag-traversal.ts (seedRunVariables), packages/triggers/trigger-record-change/src/record-change-trigger.ts (buildContext), scripts/check-empty-changeset.mjs (the DELIBERATE CORRECTION class), content/docs/deployment/validating-metadata.mdx and content/docs/automation/flows.mdx (the published spec on main), read to judge.

Inputs: card #29491 body and all four comments (triage x2, the claim, the os-dev-report with open_questions: []), PR #29582 body, file list and net diff against main (6 files, +111/-10), the check-runs on d4f21b7 read twice through the allowed read-only refresh. Judged against the tree at pr-29582 and origin/main; the PR body's account was checked, never trusted.

① Derived judgments

Accept-set changes the diff implies: none, and that is right. The only executable change outside tests is in nullGuardMessage (validate-null-guards.ts L800-805): it appends PREVIOUS_GUARD_CLAUSE(finding.operand) when finding.operand.startsWith('previous.'). findUnguardedNullableOperands, provenNonNullKeys, truthGuards, fieldOf and the flow walk in validate-expressions.ts (L2566-2718) are untouched; the second file's hunk is a doc comment on isAlwaysValuedSummary only. Which predicates either door refuses is unchanged at both os validate and the runtime publish gate.

Public-surface changes, each judged:

  • nullGuardMessage (exported from packages/lint/src/index.ts L83) returns a longer string for a previous.FIELD operand. A message-text change on an exported function with no signature change: right as a patch. fieldOf builds every rooted operand as ${root}.${seg} (L425), so the startsWith('previous.') test keys on the root alone; a bare operand (budget) and a record. operand are untouched, which the new test pins.
  • NULL_GUARD_HINT (exported, L321) is byte-identical to main, and I compared it against the runtime: cel-fault.ts L166-167 reads The ${subject.what} compares a value that is null. Guard it with '!= null' — 'has(x)' does NOT do that: a declared field holding null is still PRESENT, so has(x) is true.; the lint message reads The predicate compares a value that is null. ${NULL_GUARD_HINT} (L804). With what: 'predicate' the two are word for word the same. The "one voice, two gates" parity the existing test pins still holds; the new clause sits after it. Right.
  • PREVIOUS_GUARD_CLAUSE is module-private (const, not exported; index.ts unchanged). Right.
  • runStackExpressionPasses was already export function (validate-expressions.ts L2125); the test's new import adds no surface. Right.

The "every surface" claim the ledger and the clause make, checked in the cited code: a validation rule's previous on an insert is null (previousBinding, rule-validator.ts L3385: if (mode === 'insert') return null); a hook condition's is null (pickPreviousPayload, hook-wrappers.ts L1280: if (isInsertEvent(ctx.event)) return null); a flow's is null on the create leg (seedRunVariables, dag-traversal.ts L1179: variables.set('previous', context?.previous ?? null), fed by buildContext, where an insert's ctx.previous is undefined so materializedPrevious falls through to it, record-change-trigger.ts L532-533). requiredWhen shares evaluateValidationRules' binding. True on every covered row of the ledger.

② Semver level

  • .changeset/29491-previous-guard-hint.md: '@objectstack/lint': patch, Clause-②: no, Metadata-protocol: none. A message-text change with no accept-set change and no signature change is a patch; Clause-②: no is right because nothing a metadata author can write moves at either door; Metadata-protocol: none is right for the same reason. The PR body's two declaration lines match. No ADR-0087 disposition is owed: pr-automation.yml L992 requires one on a declared-breaking changeset only.
  • .changeset/29026-flow-condition-null-guard.md keeps minor, Clause-②: no (narrowing), Metadata-protocol: narrowed; the edit touches two prose sentences and moves no level, rightly.
  • Every factual claim in the 29491 changeset, checked: the gate's four doors (os build, os validate, os lint, the runtime publish gate through authoring-rules.ts L797) — true; previous is null on an insert on a validation rule, a hook condition and a flow's create leg — true (①); the message "now ends: guard it with previous != null && previous.FIELD != null" — true (PREVIOUS_GUARD_CLAUSE L333-335); "the shared sentence before it is unchanged and still reads word for word as the runtime's" — true (①); "which predicates are refused does not change" — true (①); "nothing to migrate" — true.
  • DELIBERATE CORRECTION, each rewritten sentence of the pending 29026 changeset judged against the live code:
    • L15 before: At the runtime publish gate bare names are not judged. After: At the runtime publish gate bare names are not judged, so a bare start-condition guard proves nothing there: a flow whose start condition is budget != nulland whose edge comparesrecord.budget > 200000passesos validateand is refused by the runtime publish gate. Spell the start guardrecord.budget != null and both accept it. True of the live code for the flow it describes: at the runtime door perWriteSnapshotEntrance() always returns an openedBy (flow-cel-root-scope.ts L490-496), so flowCelRootScope returns provable: false for every flow (L332), nullGuardBare stays empty (validate-expressions.ts L2703-2708), fieldOf returns null for the bare budget (L417-420), provenNonNullKeys proves nothing, and the edge's record.budget > 200000 is reported; at the build door the same flow (closed binders only: start, decision, end are in CLOSED_BINDER_NODE_TYPES L206-209; no variable or node id named budget) is provable, the bare guard resolves to key record.budget and the edge is presumed guarded. The record.budget != null spelling resolves at both doors through the roots arm of fieldOf. The new test pins exactly this. One wording gap: see ④ should-fix 1.
    • L17 before: A countorsumroll-up summary is now always valued for this gate (zero children is zero), on validation rules and hook conditions too;min/max/avg roll-ups stay nullable. After: the same sentence, then That presumes os migrate summary-nullsran: on a database upgraded in place without it, a parent row stored before roll-ups were seeded at create time, and never given a child since, still readsnull. True of the live code: initializeSummaryFields is create-time only and its own doc says "parents already stored with null stay null until a child write recomputes them" (engine.ts L13465-13466); the recompute's empty-set fallback runs only on a child write (aggregateSummaryValue, summary-aggregate.ts L126; summary-batch-accumulation.ts L159); summary-backfill.ts L10-20 names exactly this hole and os migrate summary-nulls as its one-off remedy; cli.mdx L1305 documents the command. One wording gap: see ④ should-fix 2.
    • The red Check Changeset on this head is the DELIBERATE CORRECTION class of scripts/check-empty-changeset.mjs (L541-563: "do NOT restore it -- say so on the PR and get it confirmed"); the PR body says so and quotes both sentences. This record is that confirmation.

③ Boundary flags

  • open_questions: [] in the os-dev-report: nothing to answer.
  • Deviation 1 (origin/main moved six commits past merge base ff827bcf; merge-tree clean): the net diff against main is the six files above and none of the moved commits touches them; CI judges the merge ref. Answered, no action.
  • Deviation 2 (commit trailer form): repository convention, not a contract matter. Answered, no action.
  • Deviation 3 (PR body carries the claim ids on a Dispatch: line): PR-body guards is green on this head. Answered, no action.
  • Claim-comment flag "the runtime stand-down arm, if measured sound and taken, re-posts this line as a narrowing": the record arm was taken, no code at either door changed, Clause-②: no stands. Answered.
  • Dev-report flag check:dual-build-cjs-loads NOT MEASURED locally: a CI family; its workflow check-runs (Build Core, Type Check · workspace, Type Check · consumer gates) are in progress at the time of this record. Recorded, not escalated: nothing in this diff touches a build entry.
  • Check-runs on d4f21b7 at my second refresh: 20 success, 9 skipped, 1 failure (Check Changeset, the deliberate-correction class judged in ②), 30 in progress (Test Core 1-8, Test UI 1-16, Build Core, Build & E2E UI, Live E2E UI, Type Check UI, Type Check · workspace, Type Check · consumer gates). Every completed lint, type-source and guard family is green. The Test Core shards hold the two edited test files; this record's PASS is on the tree I read, and the seat posts it against whatever those shards conclude.

④ Design judgment

The narrowing face is served because the PR edits a changeset carrying Clause-②: no (narrowing); this PR itself declares Clause-②: no and narrows nothing at either door. The question ④ asks is therefore whether the recorded two-door gap is a declared capability taken away at the consumer. It is not:

  • On main's published spec (validating-metadata.mdx L732-738) the runtime publish gate "builds a per-write snapshot — the written item plus the live registry's objects", and flows.mdx promises only that record / previous are total and that record.x != null is the guard. Nothing published promises that a BARE start guard is honoured at the runtime door. The capability "put the != null test in the start condition" is declared in the pending 29026 changeset, and this PR amends that pending declaration to say where a bare spelling of it does not reach, rather than changing what either door accepts. A declaration corrected before release to match what the code delivers is the opposite of narrowing at the consumer.
  • I tried to break the "stricter side stands" reasoning. At the runtime door the flow's own variables and node ids are visible, so one could argue a bare budget is provably the field whenever the flow's binders are closed. It is not: the entrance is what is invisible there, and flowCelEntrances at the build door adds an action's parameter names to the bound set — an action entrance with a budget parameter binds budget over the flattened field, so a bare budget != null guard would not guard record.budget on that launch. Deriving the presumption at the runtime door is unsound exactly as the ledger says, and a stand-down on the dependent finding would admit a flow that may fault on the only door a Studio tenant has. The build door with an unprovable bound set (a script node) refuses the same edge, so the rule at both doors is one rule, "a bare guard proves nothing where the bound set is not provable"; the runtime door's bound set is simply never provable. Recording it, with the record.budget != null cure that both doors accept, is the right arm. The test records the two doors disagreeing pins both doors as the PR claims (build door through validateStackExpressions, runtime door through runStackExpressionPasses(…, { runtimeWriteType: 'flow' }), which is the call authoring-rules.ts L797 makes at the gate).
  • Item 3's clause is sound: previous != null && previous.budget != null && previous.budget > 5 passes the gate (&& unions truthGuards, L522-525, so previous.budget is proven), and the test evaluates it with previous: null through celEngine and shows the half guard faulting; previous != null alone is still not accepted, consistently with the ledger. The clause reads the same on a record-after-update-only flow, which has no insert leg; the advice is harmless there and the guard it names is the one that holds on every leg, so I do not count it.

Findings (no must-fix):

  • should-fix 1 (.changeset/29026-flow-condition-null-guard.md L15, and only there): the corrected sentence says the bare-guarded flow "passes os validate" without the qualifier the ledger carries ("where the bound set is provable", validate-null-guards.ts L271-273). A flow with a script, wait, subflow, map or connector_action node, or a variable or node id named budget, is refused at both doors. The sentence before it names that condition, so a reader can reconstruct it, but the release note should say "passes os validate (where the flow's bound set is provable)" so the recorded trap is not read as unconditional.
  • should-fix 2 (.changeset/29026-flow-condition-null-guard.md L17): "never given a child since" under-describes the hole. summary-backfill.ts L23-26 names the pre-upgrade parent that DOES have children and was never recomputed as NULL too; what keeps the NULL is that no child was WRITTEN since, which is the wording the isAlwaysValuedSummary doc comment uses ("never visited by a child write since"). Align the changeset to the comment: "and no child of it written since".

Both are prose accuracy in a pending release note; neither changes a verdict, a message or an accept set, so neither is a FAIL.

Implemented-by: claude/project-thread-u2dp08
Reviewed-by: session_01JQwxbaBjisxkQo9hht9bFB

VERDICT: PASS

…note

The runtime-gate sentence now says `os validate` passes the bare-guard
flow only where its bound set is provable, and the roll-up sentence
spells the stale parent as one with no child written since.

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

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · os-warren · session_01JQwxbaBjisxkQo9hht9bFB

Regen-provenance: 6587299006 · d4f21b7167b4a377cb7d339f4a85edeee397cc93 → 7bf8e16f677d38475067a30eb247e450346ea7dd · git diff --name-only → .changeset/29026-flow-condition-null-guard.md

A prose-only hop: the record's two should-fixes, nothing else. The two changed changeset sentences are confirmed sentence by sentence in the seat's ACCEPT.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · os-warren · session_01JQwxbaBjisxkQo9hht9bFB

席位核对

ACCEPT · domain:devx#1 · head 7bf8e16f677d38475067a30eb247e450346ea7dd · 2026-11-02T20:13Z

  1. Shape: 6 files (+111 / -10): validate-null-guards.ts (ledger flow row, wiring point 3, PREVIOUS_GUARD_CLAUSE after the unchanged NULL_GUARD_HINT), the isAlwaysValuedSummary doc comment in validate-expressions.ts, two test files, a new @objectstack/lint patch changeset, and the pending 29026-flow-condition-null-guard.md. Inside the claim (6586327517).
  2. Premise, measured: seat read on main 208d3e2e before dispatch; the contract reviewer read perWriteSnapshotEntrance / flowCelRootScope, previousBinding, cel-fault.ts (runtime sentence byte-identical to NULL_GUARD_HINT), initializeSummaryFields and summary-backfill.ts (record 6587299006 ① ④).
  3. Gates: dev's local union at d4f21b71: 58 derived, 56 exit 0, 1 red by design (check-empty-changeset), 1 NOT MEASURED (dual-build-cjs-loads, left to CI). The hop to 7bf8e16f re-ran the changeset family (check-empty-changeset exit 1, same class; check-changeset-no-major, check-adr-0087-registration, check:nul-bytes exit 0). CI on this head is confirmed before landing.
  4. Arm taken: item 1 is recorded, not narrowed (no runtime stand-down), so Clause-②: no; the reviewer tried the stand-down and found it unsound (an action entrance can bind budget over the field).
  5. Out of scope: none.
  6. Landing, with the red: not governed, under 3000 changed lines, Clause-②: no. DELIBERATE CORRECTION confirmed, note by note. The code-bearing diff's same-head contract-review-tier PASS 6587299006 on d4f21b71 judged both rewritten sentences of 29026-flow-condition-null-guard.md true of the live code. The hop to 7bf8e16f touched prose only (Regen-provenance 6587360221), taking the record's two should-fixes, and its two changed sentences are confirmed here.
    • L15, before (at d4f21b71): "…a flow whose start condition is budget != null and whose edge compares record.budget > 200000 passes os validate and is refused by the runtime publish gate." After: "…passes os validate (where the flow's bound set is provable) and is refused by the runtime publish gate." True: a flow with a script / connector_action / wait / subflow / map node or a variable named budget is unprovable at the build door too and refused at both (record ④ should-fix 1).
    • L17, before: "…a parent row stored before roll-ups were seeded at create time, and never given a child since, still reads null." After: "…and no child of it written since, still reads null." True: what keeps the NULL is the absence of a child write since, per summary-backfill.ts and the isAlwaysValuedSummary comment (record ④ should-fix 2).
      Red-by-design, all three conditions: pr-automation.yml says to leave Check Changeset red for this class and that it is not a required context; the workflow runs on pull_request only, never merge_group; this comment records the gate and the reason. Seat lands it: pr_ready + automerge_enable once every other check on this head is green or an expected skip.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 20:56
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit e7d971d Nov 2, 2026
66 of 68 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-u2dp08 branch November 2, 2026 21:20
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lint null-guard gate: three accuracy follow-ups from the contract review of the flow-condition widening

1 participant