Skip to content

fix(lint)!: a flow condition's text-vs-number comparison gets a true verdict on every flow, record.x read and decision branch - #30015

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

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-flea8r

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #29959

维护者速读

  • 改了什么:流程条件里「文本或布尔字段和数字比大小」(如 name > 5)现在每个位置都拿到真实判词。记录触发流程里写成 record.name > 5、或写在 decision 分支里,发布时和裸字段名一样被拒(error,文案说运行在该条件处失败、触发写入照常落库);非记录触发流程(定时、API、手动/动作启动)仍是 warning,但文案从「静默得到 null」改为如实说明:条件出错时运行失败。MCP validate_expression 新增可选输入 severity(校验规则)和 triggerKind(流程触发方式),答案与发布门一致。
  • 为什么改:expression gate: a type mismatch in a record-triggered flow condition (and in an advisory validation rule) is served a verdict whose text is false for that role #29926 之后还有三处判词缺失或失真:非记录触发流程的 warning 说「得到 null」(实际 evaluateCondition 在每种流程上都抛错、运行失败);record.x 从不做类型判断;decision 分支没有字段类型,name > 5 运行必失败却无任何提示。MCP 工具也无法表达规则严重级别和流程触发方式,答案与发布门不一致。
  • 风险与回滚:收窄发布门:已有记录触发流程若在 record.x 读取或 decision 分支里有这种比较,下次发布会被拒(它们每次运行本来就会失败)。流程条件上的 warning 代码由 type-mismatch 改为 type-mismatch-run-may-fail(18.3.0 的 formula / mcp 在流程条件上发的是 type-mismatch,按代码识别的使用方需改认)。MCP 新输入均可选,不传时答案与之前相同。回滚即 revert 本 PR,无数据迁移。
  • 先看:packages/formula/src/cel-engine.ts(firstTypeMismatch 的 record 根第二遍)、packages/formula/src/validate.ts(type-mismatch-run-may-fail 分支)、packages/lint/src/validate-expressions.ts(checkDeclaredPredicate 的 decision 分支走 check())。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/publish-type-mismatch-fail-closed.test.ts,预期 9 条全过:记录触发流程 record.name > 5 与 decision 分支 name > 5 被拒且文案说运行失败;定时流程同样写法发布成功、warning 文案说运行失败而不说 null;类型正确的对照发布无判词。
  • 需要你做的:无,席位落地(收窄,席位在落地前做 contract review)。

Clause-②: yes (narrowing) — the author-time gate (os build, os validate, the package publish and the flow publish door) refuses, on a record-triggered flow, a type-mismatch in a decision branch and in a record.FIELD / previous.FIELD read of a start or edge condition, which it passed with no verdict before; refuses the same on a time-relative flow sweeping the object its conditions are typed against, where 18.3.0 served a warning; and a flow condition's type-mismatch warning is served under its own code type-mismatch-run-may-fail instead of type-mismatch. Added beside them (the widening half): the optional severity and triggerKind inputs of the validate_expression MCP tool, the code type-mismatch-run-may-fail in @objectstack/formula's ExpressionRefusalCode, the optional flowVariables key of @objectstack/formula's ExprSchemaHint, and a bare flow-condition name that a declared flow variable or node id holds is no longer typed as the field it shadows (no verdict where 18.3.0 served a type-mismatch warning).
Metadata-protocol: narrowed
Skills-docs: updated here — content/docs/data-modeling/formulas.mdx (the type-soundness row names the decision branch, the record.FIELD read and the warning on other flows) and the MCP skill text in packages/mcp/src/skill-md.ts (the two new validate_expression inputs); skills/** teaches no text-vs-number comparison in a flow condition (grep for type mismatch, evaluates to null, validate_expression and the decision examples), so no skill or eval moves.

What changes

Re-measured on main at 73a3ef9f (after #29953 9037b13d). The four sites, and what each now gets:

site runtime on a fault (measured) before now
record-triggered flow: record.FIELD / previous.FIELD in a start or edge condition seedRunVariables binds the trigger record as record AND flattened to bare names, so record.name is the bare name; evaluateCondition throws, the run fails, the write lands no verdict (the flattened typing left record a dyn map) error type-mismatch-run-fails, the bare name's verdict
record-triggered flow: decision branch config.conditions[].expression builtin/logic-nodes.ts evaluates it over the same run variables, through the same evaluateCondition no verdict (checkDeclaredPredicate passed no schema hint); the null-guard twin alone refused error type-mismatch-run-fails
any other flow (no triggerType, schedule, time-relative, api), all three slots; an inert node condition evaluateCondition throws on every flow, so "silently evaluates to null" was false; but the run holds what its entrance hands it warning type-mismatch, "silently evaluates to null" (start/edge only) warning type-mismatch-run-may-fail, worded for the failed run (start, edge, decision, record.FIELD)
MCP validate_expression — validation always fail-closed, flow_condition always run-fails optional severity / triggerKind inputs; each combination answers the gate's verdict

Site 1, grading (seat's ruling: grade as #29953 did if the same runtime fact holds; keep the warning with true text if a reason is measured). I kept the warning, and the reason is measured on the entrances, not assumed. The run does fail on a fault in every flow (dag-traversal.ts evaluateCondition, ADR-0032 §1c). What differs is whether the condition faults at all: on a record-triggered flow the bare name and record.FIELD are the materialized trigger record (record-change-trigger.ts buildContext → seedRunVariables), so a text field holds text and name > 5 faults unless the text is numeric. On any other flow seedRunVariables binds whatever context.record the entrance hands over (engine.ts celScope docblock, #22642): the inbound hook's request body (trigger-api, a JSON body whose name may be a number), a type: 'flow' action's loaded row or an empty record carrying only id, a parent flow's record (subflow / map), or nothing for a cron schedule or the REST trigger route. So the object's field type is not proven to be what the name holds, and refusing there would refuse conditions that can run. This is the same measurement that keeps the nullable-comparison twin off those flows (validate-null-guards.ts ledger, "sparse or unknown"). The warning now says what happens when it does fault: "a flow condition that faults when it is evaluated fails the run: the flow stops at this condition and the work it exists to do is not done (it does not read null)", and why it is a warning.

Where the text is decided (@objectstack/formula). A 'flattened' scope is a flow condition, the only caller that passes one with field types (lint's flow walk, the engine's registration advisory pass, MCP flow_condition). So typeSoundnessIssue serves type-mismatch-run-may-fail for a flattened site with no declared outcome, and the record-scoped type-mismatch (formula fields, display predicates: there a fault reads null or never refuses) keeps its code and text. The engine's registration log line reads the new text too (it passes no outcome).

Site 2, record.FIELD (cel-engine.ts firstTypeMismatch). The flattened typing binds fields bare and leaves record / previous dyn. When that first pass finds nothing and the source reads record. or previous., a second pass types those two roots with the record-scope struct (buildTypedEnv(…, flattenedRecordRoots)), for the text/boolean mismatch only, and offendingRecordRead names the read as written (record.title). Same judge, same schema hint, same outcome; no second judge. The date verdicts stay the bare name's alone (see Acceptance notes).

Site 3, decision branches (@objectstack/lint). checkDeclaredPredicate takes an optional flowCondition hint; a decision node passes { objectName, faultOutcome: flowFaultOutcome } and the slot goes through the same check() an edge goes through. So the branch gets the object's fields and types and the flow's ledger outcome (run-fails on a record-triggered flow, none elsewhere). The other declared predicate slots (a screen field's visibleWhen) bind the screen's collected values and keep the hint-less call. The faultOutcomeOf ledger docblock now carries the decision row, the record.FIELD reading and the "any other flow" row with its reason.

Site 4, MCP (mcp-http-tools.ts). I took the "add the inputs" option. severity (error | warning | info, default error) and triggerKind (FLOW_TRIGGER_KINDS from @objectstack/spec/automation plus none, default record_change). VALIDATE_SITE_MAP[site].faultOutcome is now a function of the two, mirroring lint's faultOutcomeOf (validation: warning/info → fail-open, else fail-closed; flow_condition: record_change → run-fails, else none). A call that passes neither answers exactly as before. An input on a site it does not describe is refused as a tool error ("severity describes a validation rule; it applies only with site "validation" …"). The tool description and the served skill text name both inputs. This is a widening of a published tool schema, declared in the Clause-② line.

@objectstack/spec. New semantic entry 18.flow-record-read-and-decision-type-mismatch-refused, the changeset's ADR-0087 disposition (registered). The #29953 entry flow-condition-type-mismatch-refused is left as is: its text (start and edge, bare name; other flows keep a warning) is still true.

Tests

  • packages/formula/src/expression-refusal-codes.test.ts: PINS for type-mismatch-run-may-fail (bare text, record.active boolean: exact params and message). SWEEP_SCHEMAS gains a flattened deal schema with no outcome, so the sweep reaches the new code and holds its params to its message.
  • packages/formula/src/validate.test.ts: record.name, previous.name, record.is_active in a flattened run-fails site → type-mismatch-run-fails naming the read; controls size(record.name) > 5, double(record.name) > 5, record.amount > 5, record.name == 'x', record.is_active == true clean. Flattened with no outcome → type-mismatch-run-may-fail, not "evaluates to null"; the record-scope warning keeps type-mismatch.
  • packages/lint/src/validate-expressions.test.ts:
  • packages/mcp/src/mcp-validate-expression.test.ts: gate parity. For each severity (omitted, error, warning, info) and each triggerKind (omitted, record_change, time_relative, schedule, api, none), the tool's (severity, message) list equals validateStackExpressions's on the same rule or flow. An input on the wrong site is refused; well-typed conditions are clean at every input.
  • packages/objectql/src/publish-type-mismatch-fail-closed.test.ts, new top-level describe in the existing harness: through the real saveMetaItem (draft) + publishPackageDrafts door, a record-triggered record.name > 5 start condition and a name > 5 decision branch are refused with the run-fails text; the same comparisons on a schedule flow publish with two warnings saying the run fails; a well-typed control publishes clean.
Suites (head 5a5fd5b, under os-verify-lock, shared box)
formula test       Test Files 64 passed (64)    Tests 1637 passed (1637)
lint test          Test Files 193 passed (193)  Tests 7971 passed (7971)
mcp test           Test Files 55 passed (55)    Tests 672 passed (672)
objectql publish-type-mismatch-fail-closed.test.ts   Tests 9 passed (9)
spec src/migrations (after gen:migration-registry)   Test Files 5 passed (5)  Tests 197 passed (197)
typecheck (tsc --noEmit + check:test-typecheck): formula 0, lint 0, mcp 0, objectql 0
  (the first run caught 1 test-layer error in formula and 2 in mcp, in the new pins; fixed in 5a5fd5b8)
turbo run build (all) — 120/120 tasks, exit 0
Ablations (each through scripts/ablation-replace.mjs; each restored: blob == HEAD, git diff HEAD empty)
A1  formula: the record-root pass removed (`return null; /* ablated-29959-a1 */`)
    × judges a flattened condition's record.FIELD and previous.FIELD reads as its bare name (#29959)
    × warns with the run-may-fail wording on a flattened condition with no declared outcome (#29959)
    × type-mismatch-run-may-fail (PINS) · × the SWEEP_SCHEMAS sweep (code set unreached)
    Tests 4 failed | 166 passed (170)
A2  formula: the flattened warning arm off (`if (false as boolean /* ablated-29959-a2 */)`) — the old
    "silently evaluates to null" warning comes back on a flow condition
    × warns with the run-may-fail wording … · × type-mismatch-run-may-fail (PINS) · × the sweep
    Tests 3 failed | 167 passed (170)
A3  lint: the decision branch hint off (`const branchCondition = undefined; /* ablated-29959-a3 */`)
    × keeps the warning on a flow that is not record-triggered, worded for the run it fails
    × refuses a type mismatch in a record.x read and a decision branch of a record-triggered flow
    Tests 2 failed | 459 passed (461)
A4  mcp: the two inputs ignored (severity 'error', triggerKind 'record_change' always)
    × site validation, severity warning · × severity info
    × site flow_condition, triggerKind time_relative · × schedule · × api · × none
    Tests 6 failed | 29 passed (35)

Acceptance notes

  • Date verdicts in a flow condition still say "silently evaluates to null". date-arithmetic / date-text-arithmetic on a bare date field in a flow condition are errors whose text says the field reads null; in a flow the run fails instead. Pre-existing, and outside this card's type-mismatch scope, so the record.FIELD pass here deliberately does not extend the date verdicts to record.d + 1 (it would serve that false sentence at a new site). Reported to the seat as a finding.
  • A declared flow variable named like a text field. A record-triggered flow that declares variables: [{ name: 'title', type: 'number', defaultValue: 3 }] over a text field title gets title > 5 refused as type-mismatch-run-fails ("title holds text"), though the run seeds the declared variable first and the flattening skips a bound name, so the condition reads the number (measured through the built lint dist on this head; the shadowing warning fires beside it). Present on main since fix(lint)!: a type mismatch in a record-triggered flow condition or an advisory validation rule is served a verdict that says what really happens #29953, the same on this branch; the type judge reads the object's field types without the declared-variable exclusion the null-guard twin applies (nullGuardBare). Fixing it needs the hint to exclude names from the bare typing but not from record.FIELD, a new schema-hint key; reported to the seat as a finding, not fixed here.
  • Advisory and inert sites unchanged in grading. An inert node condition keeps a warning (now the run-may-fail text, which is true: it fails the run if it is ever evaluated, and flow-inert-node-condition says it is not).
  • Serial check. None of the 16 open PRs at 2026-11-04T05:20Z touches packages/formula/src/{validate,cel-engine,expression-refusal}.ts, packages/lint/src/validate-expressions.ts or packages/mcp/src/mcp-http-tools.ts (the nearest: refactor(metadata-protocol): generate the protocol.ts section split with a deterministic codemod #29998 metadata-protocol split, refactor(spec): codemod split of conversions/registry.ts into per-entry modules (#29987) #29993 spec conversions registry split; chore: version packages #29963 is version packages).

Rework after the contract review (head 6a3a1e2)

  • A bare name a flow variable holds is not typed as the field. seedRunVariables seeds the flow's declared variables and node outputs before it flattens the record, and the flattening skips a bound name. New optional ExprSchemaHint.flowVariables (a widening): lint passes the flow's collectFlowVariableNames set plus the engine-bound names at the start, edge, decision and inert node sites, and the type judge leaves those bare names dyn (date verdicts too), while record.NAME / previous.NAME stay typed. This also closes the start/edge instance on main from fix(lint)!: a type mismatch in a record-triggered flow condition or an advisory validation rule is served a verdict that says what really happens #29953.
  • Time-relative sweeps are graded run-fails. time-relative-trigger.ts launches each run with { record: row }, a stored row of the swept object, so on a flow whose timeRelative.object is the start node's objectName (or is omitted) the ledger, faultOutcomeOf and the MCP flow_condition map answer run-fails. A sweep of another object keeps the warning. The null-guard twin stays on record-triggered flows.
  • Wording. The run-may-fail sentence no longer asserts the flow's kind (it is also served on inert node conditions and in the registration log); the stale check() and ExprSchemaHint.fieldTypes comments are refreshed.
  • New pins. lint: time-relative default / same object (errors) vs another object (warnings) at start, decision and edge; a record-triggered flow declaring title as a number variable: bare title > 5 at start, edge and decision gets no type verdict (shadow warning only), record.title > 5 stays refused; the validate-expressions / validate-security-posture 也有同形的 spec 不声明键的 ?? 别名读法(#5009 建议 3 的核对结果) #5017 table gains the sweep receiver against TimeRelativeTriggerSchema. formula: flowVariables leaves bare names untyped and keeps record. / previous. typed. mcp: each trigger kind's severity pinned, not only parity.
  • Ablations. B1 lint hint not passed: 1 failed. B2 lint time-relative grading off: 1 failed. B3 formula bareShadowed ignored: 1 failed. B4 mcp time-relative off: 1 failed. Each restored, blob == HEAD.

Verification

All at head 5a5fd5b8 (git rev-parse --short HEAD), the commit this PR opens on.

  • node scripts/pm/dispatch-gates.mjs --commands: 107 derived families, each run after the full build, all exit 0. Re-derived just before reconciling (same 107). --ran: "107 derived famil(ies) accounted for — 107 run, 0 NOT-MEASURED". The clone was unshallowed first. Among them: check-adr-0087-registration --base origin/main (registered flow-record-read-and-decision-type-mismatch-refused, new here), check-changeset-no-major, check-empty-changeset, spec check:spec-changes / check:upgrade-guide, check:test-source-alias, check:cross-package-test-inputs, check:objectql-double-limit, check:engine-double-contract.
  • pnpm lint (eslint . --no-inline-config): exit 0.
  • Full dogfood (pnpm --filter @objectstack/dogfood test under the lock; narrowing arm): VERDICT command-exit 0, Test Files 416 passed | 3 skipped (419), Tests 3456 passed | 28 skipped (3484).

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzf4UmgvS2cusNbWnvy

…y flow, record.x and decision branch

WIP: formula flattened-scope warning arm and record-root typing, lint decision
branches through the edge's schema-hint path, MCP validate_expression severity
and triggerKind inputs.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzf4UmgvS2cusNbWnvy
…ate parity

Formula pins for type-mismatch-run-may-fail and the record-root pass, lint
pins for record.x / decision on record-triggered and other flows, MCP
validate_expression parity against the gate for each severity and trigger
kind, publish-door cases, the ADR-0087 semantic entry and the changeset.

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

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/formula, @objectstack/lint, @objectstack/mcp, @objectstack/spec, touching 29 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/mcp/src/skill-md.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

14 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 faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line))
  • content/docs/api/plugin-endpoints.mdx (via record_change (literal, a string literal in faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line))
  • content/docs/automation/approvals.mdx (via record_change (literal, a string literal in faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line))
  • content/docs/automation/flows.mdx (via validateExpression (symbol, a top-level function), record_change (literal, a string literal in faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line), time_relative (literal, a string literal in faultOutcomeOf; a string literal on a changed line))
  • content/docs/automation/hooks.mdx (via record_change (literal, a string literal in faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line))
  • content/docs/automation/workflows.mdx (via record_change (literal, a string literal in faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line))
  • content/docs/concepts/architecture.mdx (via record_change (literal, a string literal in faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line))
  • content/docs/data-modeling/formulas.mdx (via validateExpression (symbol, a top-level function))
  • content/docs/getting-started/common-patterns.mdx (via record_change (literal, a string literal in faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line))
  • content/docs/kernel/services-checklist.mdx (via record_change (literal, a string literal in faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line))
  • content/docs/permissions/capabilities.mdx (via record_change (literal, a string literal in faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line), time_relative (literal, a string literal in faultOutcomeOf; a string literal on a changed line))
  • content/docs/permissions/system-context.mdx (via record_change (literal, a string literal in faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line))
  • content/docs/protocol/objectql/schema.mdx (via record_change (literal, a string literal in faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line))
  • content/docs/ui/actions.mdx (via record_change (literal, a string literal in faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line))

⛔ 4 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 faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line))
  • content/docs/releases/v16.mdx (via time_relative (literal, a string literal in faultOutcomeOf; a string literal on a changed line))
  • content/docs/releases/v17/17-0.mdx (via record_change (literal, a string literal in faultOutcomeOf; a string literal in registerObjectTools; a string literal in runStackExpressionPasses; a string literal on a changed line))
  • content/docs/releases/v17/17-5.mdx (via time_relative (literal, a string literal in faultOutcomeOf; a string literal on a changed line))

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 changed file(s) yielded no anchor (packages/mcp/src/skill-md.ts) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 175 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 63b86b385dd2cbfeba131438112c49be2c74df91 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 63b86b385dd2cbfeba131438112c49be2c74df91

⚠️ 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 63b86b385dd2cbfeba131438112c49be2c74df91 → 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:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5a5fd5b8b610f8d0f7c2df8116d81f3dd514c002
Local-runs: none
Scope: card #29959 and its comments (6611484778, 6622658032, 6623979276); the seat order and /mnt/project-files/issue-29959/seat-report.json; the PR body, files and full head diff (14 files) and the head's check-runs. At pr-30015: the changeset; in packages/formula, cel-engine.ts (buildTypedEnv, offendingRecordRead, firstTypeMismatch), validate.ts, expression-refusal.ts, index.ts and their tests; in packages/lint, validate-expressions.ts (the ledger, check, the flow walk and the start, decision and edge sites), validate-null-guards.ts, flow-variable-scope.ts and the test; in packages/mcp, mcp-http-tools.ts, mcp-validate-expression.test.ts and skill-md.ts; packages/objectql/src/publish-type-mismatch-fail-closed.test.ts; the two 18.*-type-mismatch-refused.ts semantic entries, migrations/types.ts, automation/flow-trigger-kind.ts and the spec ./automation export. Cited code read, not reviewed: service-automation engine.ts (celScope, seedRunVariables), dag-traversal.ts (evaluateCondition), builtin/logic-nodes.ts, flow-registration-checks.ts:460, trigger-api/src/api-trigger.ts:289–292, trigger-schedule/src/time-relative-trigger.ts:645–657. On main: 9037b13d's changeset (the precedent), check-adr-0087-registration.mjs, AGENTS.md step 3, SKILL.md:313, contract-review.md, review-checklist.md; PR #29953's reviews 6610931370 and 6611149064.

Face: narrowing (Clause-②: yes (narrowing) on PR body and changeset), plus a published spec path (a new migrations/entries/semantic entry) and a published MCP tool input schema widened (validate_expression gains optional severity / triggerKind). Run in-seat as an isolated reviewer at CONTRACT_REVIEW_TIER, fed the card, the PR and the head's check-runs only.

① Derived judgments

  • Site 2, record.FIELD / previous.FIELD in a record-triggered flow → type-mismatch-run-fails: correct. seedRunVariables binds context.record as record and flattens the same row's fields to bare names, so record.name and name are one value. evaluateCondition throws on a non-ok evaluation for every flow, so the run fails and the write still lands. The second typed-root pass runs only when the bare pass found nothing. It admits only a text/boolean overload hit, and the date verdicts stay on the bare name. It uses the same judge, hint and outcome token, with no second judge. A struct-typed record.unknown can only be a false negative, never a false positive.
  • Site 3, decision branches → the edge's verdict: correct and exact. The call is checkDeclaredPredicate(…, { objectName, faultOutcome: flowFaultOutcome }) for decision only, which is byte for byte the edge's check() call. logic-nodes.ts evaluates each branch through engine.evaluateCondition over the same variables. Other declared predicate slots keep the hint-less call. The ledger's decision row now matches the code.
  • Site 1, non-record-triggered flows keep a warning (type-mismatch-run-may-fail): right for the entrances measured, with one omission. The API trigger binds the request body as record, a type: 'flow' action a loaded row or {id}, subflow/map the parent's context, and a cron schedule nothing. In none of these is the field's type proven, which is the same measurement that keeps the null-guard twin off those flows, and the new sentence is true. But time_relative is omitted: time-relative-trigger.ts:645–657 sweeps the declared object and launches each run with { record: row }, the object's own row, exactly as a record-change trigger does. For that kind the dev's reason does not hold. It is a missed refusal behind a true, hedged sentence, not a false positive (REWORK-2).
  • Declared-variable false positive (dev finding 1): confirmed, and this PR extends it to a new site. seedDeclaredVariables runs first and the flattening skips names already bound. A record-triggered flow that declares variables: [{ name: 'title', type: 'number', defaultValue: 3 }] therefore reads the number under bare title, and title > 5 runs. check() hands the object's field types to the judge with no exclusion of declared variables (the null-guard twin excludes them via nullGuardBare). The bare name is typed as the field and refused type-mismatch-run-fails with "title holds text", which is false. This has been on main at start and edge since fix(lint)!: a type mismatch in a record-triggered flow condition or an advisory validation rule is served a verdict that says what really happens #29953. After this PR the same flow's decision branch is refused too, where it published before. The refusal sits beside a flow-variable-shadows-field warning that says the opposite. record.title is typed correctly, since it reads the field and not the variable. The reach is narrow, but it is a condition that runs fine now refused at a site this PR adds (REWORK-1).
  • Code rename type-mismatch → type-mismatch-run-may-fail on a flattened site: correct, and inside the narrowing clause with FROM → TO naming 18.3.0, as fix(lint)!: a type mismatch in a record-triggered flow condition or an advisory validation rule is served a verdict that says what really happens #29953's review asked. The record-scoped warning keeps its code and its true text. CODE_TABLE and PINS reach the new code.
  • Site 4, MCP: correct. VALIDATE_SITE_MAP[*].faultOutcome mirrors faultOutcomeOf predicate for predicate, the defaults reproduce the pre-PR answers, and an input on the wrong site is a tool error. Parity with validateStackExpressions is pinned for 4 severities and 6 trigger kinds.
  • Nits: the run-may-fail sentence's clause "on a flow that is not record-triggered, whatever started it" is also served on a record-triggered flow's inert node condition and by the engine's registration log line on every flow. The comment on check() saying fieldTypes is "only consulted for record-scoped sites" is now plainly false.

② Semver level

minor on @objectstack/formula, @objectstack/lint, @objectstack/mcp and @objectstack/spec. This is consistent with Metadata-protocol: narrowed, which takes at least minor on every package the diff moves. objectql moves only in a test file, so it is correctly unlisted. Clause-②: yes (narrowing) is well-formed and identical in the PR body and changeset, and it has the shape the ADR-0087 gate's own fixture names as breaking. The changeset has !, the BREAKING banner, FROM → TO, and one marker registered flow-record-read-and-decision-type-mismatch-refused, which resolves to the new entry. Skills-docs: updated here — FILES … is in the closed vocabulary, and the docs it names move in this diff. Level judged right.

③ Boundary flags

④ Narrowing face

  • FROM → TO is present and sufficient, and each refusal lands at its slot path with the conversion. The objectql door test proves it through saveMetaItem + publishPackageDrafts.
  • The entry's shape matches SemanticMigration, and its reason states the runtime facts verified in ①. The ledger, the entry, the MCP map and the gate are consistent with one another. Their one shared blind spot is the declared-variable exclusion (REWORK-1).
  • Pins and ablations A1–A4 are adequate for what they pin. Gaps: no pin covers a declared variable that shares a field's name at any flow site, and time_relative is pinned only as "equals the gate's warning".

Implemented-by: claude/project-thread-flea8r
Reviewed-by: session_01WGufzibcN9oHa8kDbTTnL5

VERDICT: REWORK

REWORK items:

  1. (blocking) Exclude the flow's declared variable names (and node ids, as collectFlowVariableNames returns them) from the bare typing at the flow sites, keeping record.FIELD / previous.FIELD typed. If a new ExprSchemaHint key is added, declare it as a widening. Pin a record-triggered flow with variables: [{ name: 'title', type: 'number', defaultValue: 3 }]: title > 5 at start, edge and decision gets no type verdict (the shadow warning only), while record.title > 5 on the same flow stays refused. Ablate once. This also closes main's start/edge instance from fix(lint)!: a type mismatch in a record-triggered flow condition or an advisory validation rule is served a verdict that says what really happens #29953.
  2. (should-fix) time_relative: either grade it run-fails across the ledger row, faultOutcomeOf, the MCP flow_condition map, the trigger-kind pins and the changeset's "any other flow" bullet, or add the measured reason for keeping the warning to the ledger row.
  3. (nit) Word the run-may-fail sentence so it does not assert the flow kind, and refresh the stale check() comment.

… a time-relative sweep is graded like a record trigger

A flow run seeds its declared variables and node outputs before it flattens
the record, and the flattening skips a bound name, so a bare read of such a
name holds the variable: the type check no longer types it as the field it
shadows (new optional ExprSchemaHint.flowVariables), while record.NAME and
previous.NAME stay typed. A time-relative sweep launches each run with the
swept row as its record, so a sweep of the object the conditions are typed
against gets run-fails at the start, edge and decision sites, in the lint
ledger and the MCP flow_condition map alike. The run-may-fail warning no
longer asserts the flow's kind.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzf4UmgvS2cusNbWnvy
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6a3a1e2c4365a20865484c491e7d040cf39da969
Local-runs: none
Scope: the REWORK record 6624166953 in full. The one-commit delta 5a5fd5b8..6a3a1e2c (12 files, +298/−64), read whole:

  • the changeset and content/docs/data-modeling/formulas.mdx;
  • packages/formula/src/{cel-engine,validate}.ts and their tests;
  • packages/lint/src/validate-expressions.ts and its test;
  • packages/mcp/src/{mcp-http-tools,skill-md}.ts and mcp-validate-expression.test.ts;
  • the semantic entry 18.flow-record-read-and-decision-type-mismatch-refused.ts.

The replacement body /mnt/project-files/issue-29959/pr-body.md, read against the live body.

Cited code read at the head:

  • buildTypedEnv, firstTypeMismatch, withoutNames;
  • faultOutcomeOf, FLOW_ENGINE_BOUND_NAMES, nullGuardBare, warnShadowedFieldReads, and the start, decision, edge and inert-node check() calls;
  • collectFlowVariableNames, and the module note of flow-cel-root-scope.ts;
  • validate-flow-trigger-readiness.ts (TimeRelativeTriggerSchema.safeParse), and TimeRelativeTriggerSchema (object required);
  • time-relative-trigger.ts: L334–360, L598 engine.find(desc.object, …), L657 { record: row }, L714–750;
  • seedRunVariables (dag-traversal.ts L1164–1184), and flow-registration-checks.ts L455–480.

The head's 65 check-runs.

Face: delta review of the fix-up after REWORK 6624166953 (head 5a5fd5b8). It covers the three items, the two new findings the dev reports as unfixed, and the replacement PR body.

① Derived judgments

  • Item 1 is closed by code, and its shape is correct.
    • Formula: ExprSchemaHint.flowVariables?: readonly string[] is new and optional, and only the flattened scope reads it. The bare pass types withoutNames(fieldCelTypes, shadowed), so a shadowed bare name stays dyn and cannot hit an overload. The record-root pass still builds record / previous from the full field map. The date verdicts receive the same filtered map. A record-scoped site ignores the key, and that is pinned.
    • Lint: it builds [...collectFlowVariableNames(flow), ...FLOW_ENGINE_BOUND_NAMES] once per flow and passes it at the start, inert-node, decision and edge sites. warnShadowedFieldReads is unchanged, so the shadow warning stays.
    • Pins: the lint pin is the one REWORK-1 asked for. In a record-triggered flow with variables: [{ name: 'title', type: 'number', defaultValue: 3 }], bare title > 5 at start, edge and decision gets no type verdict, while the record.title > 5 branch is the only error. The formula pin covers bare, record., previous., an unnamed field in the same source, and the record-scope no-op.
    • This also closes main's start/edge instance from fix(lint)!: a type mismatch in a record-triggered flow condition or an advisory validation rule is served a verdict that says what really happens #29953. Ablations B1 and B3 are reported, and the named pins are the ones that would catch them.
  • Item 2 is closed by code, and the measurement is right.
    • The trigger finds rows of desc.object and launches each run with ctx.record = row. seedRunVariables binds it and flattens it as a record-change run does. When timeRelative.object is the object the conditions are typed against, bare name and record.name are that field.
    • Every place says the same thing: the ledger row, FaultSite.sweepsTypedObject, faultOutcomeOf, the MCP flow_condition map, the tool description, the skill text, the changeset, the entry's FROM / reason / acceptance, and the docs row.
    • A sweep of another object keeps the warning, which is correct. The null-guard twin stays on record_change only, with its reason stated: previous is bound only there.
    • Pins: lint tr_same → error and tr_other → warning at start, decision and edge. The MCP pins now check each trigger kind's severity.
    • One prose inaccuracy, which no verdict reaches: an omitted timeRelative.object is described as sweeping the start object. The schema requires the key, and readiness refuses its absence (flow-time-relative-descriptor-invalid), so that arm grades a flow that never binds. This is a nit, filed.
  • Item 3 is closed. The run-may-fail sentence now reads "depends on what the run binds, which this check cannot prove here". That is true at every site that serves it, including the engine's registration log. The check() comment and the fieldTypes docblock now match the code.
  • New finding 1 is real and pre-existing. Screen-field names, and names bound by an open binder, are not excluded. collectFlowVariableNames does not collect a screen's field names, and nothing can see what a script, connector_action, wait, subflow or map node binds. The null-guard twin has the same blind spot, but it stands down when rootScope.provable is false, and the type check does not. This has been on main at start and edge since fix(lint)!: a type mismatch in a record-triggered flow condition or an advisory validation rule is served a verdict that says what really happens #29953. The decision site carries it too, but REWORK-1's scope (declared variables and node ids) is met. The reach is narrow. Filed.
  • New finding 2 is real and pre-existing, and not made worse. When the start names no objectName, a time-relative flow that names only timeRelative.object gets no type verdict at all. That is a missed verdict, not a false refusal. Filed.
  • MCP: triggerKind: 'time_relative' assumes the swept object is objectName, and the input description states that assumption. This is an acceptable approximation, and it is filed with finding 2.

② Semver level

The level is unchanged and still right: minor on @objectstack/formula, @objectstack/lint, @objectstack/mcp and @objectstack/spec, with Metadata-protocol: narrowed. The narrowing gains one arm: a time-relative sweep of the typed object moves from warning to refusal. That arm is declared FROM → TO in the changeset, in the entry's from / reason / acceptanceCriteria, and in the Clause-② line. The widening half now names the optional flowVariables key, and the behavioural widening (no verdict where 18.3.0 warned on a shadowed bare name). Clause-②: yes (narrowing) is well-formed and identical in the changeset and the replacement body.

③ Boundary flags

  • The "omitted timeRelative.object" wording is wrong in three places: the lint ledger comment, the changeset bullet and the body's rework section. No verdict depends on it. Filed.
  • The replacement body's Verification section is still dated at 5a5fd5b8. The head's CI is taken as the head's measurement: 65 check-runs, all completed, 0 failed, with Test Core 8/8, dispatch-gates, Check Changeset, the PR-body guards and the type checks all green. The dev also reports 107 gates with exit 0 at 6a3a1e2c.
  • The engine's registration log line passes no flowVariables. It is a log only, and the sentence it serves is true. Not a gate.
  • No open_questions. Mergeable state is clean.

④ Narrowing face

  • FROM → TO is present for every refused shape, including the time-relative arm. Each refusal lands at its slot path with the same conversion text. The entry's reason states the measured runtime facts.
  • The ledger, faultOutcomeOf, the MCP map, the entry and the docs row agree with one another and with the code. The blind spot REWORK-1 named is closed for declared variables and node ids. The residual (screen fields, open binders) is the twin's too, and is filed rather than fixed.
  • The replacement body is consistent with the diff. Skills-docs: updated here — FILES is in the closed vocabulary, and both files it names move in the delta. The seat applies the replacement.

Implemented-by: claude/project-thread-flea8r
Reviewed-by: session_01WGufzibcN9oHa8kDbTTnL5

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

席位核对

Verdict: ACCEPT at head 6a3a1e2c4365a20865484c491e7d040cf39da969 · 2026-11-04T07:59Z

  • Shape: flow-condition type verdicts at every site.
    • record.FIELD / previous.FIELD and decision branches: refused type-mismatch-run-fails on a record-triggered flow, and on a time-relative flow that sweeps the typed object.
    • Other flows: the warning moves to the code type-mismatch-run-may-fail, with true text.
    • Declared flow variables and node ids are no longer typed as the field they shadow.
    • MCP validate_expression: gains optional severity / triggerKind.
    • One ADR-0087 semantic entry, and minor changesets.
  • Reviews: CONTRACT_REVIEW_TIER review 6624166953 (REWORK, three items), then a delta PASS at this head.
  • Premise measured: the four sites were re-measured on main after 9037b13d. The real publish door refuses through saveMetaItem + publishPackageDrafts. Ablations: A1–A4, then B1–B4, each 1 red and restored.
  • Declaration: Clause-②: yes (narrowing), Metadata-protocol: narrowed, minor on @objectstack/formula, @objectstack/lint, @objectstack/mcp and @objectstack/spec, BREAKING, adr-0087: registered flow-record-read-and-decision-type-mismatch-refused. Reviewed as correct.
  • Gates (head): formula 1638, lint 7974, mcp 672, objectql door 9, spec migrations 197; typecheck 0 on four packages; 107 dispatch-gates exit 0; build 120/120; dogfood 416 files / 3456 tests. CI: 65 check-runs, 0 failed.
  • Accepted deviations: the harness-pinned branch claude/project-thread-flea8r. Out of scope and filed by the seat: screen fields and open binders not excluded, a time-relative flow with no start objectName gets no typing, and the "omitted timeRelative.object" wording. The seat applies the signed trailer and the replacement body through the relay.
  • Landing: on all-green at this head, the seat sends pr_ready + automerge_enable through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 4, 2026 08:01
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit e8afd1e Nov 4, 2026
70 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-flea8r branch November 4, 2026 08:28
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/xl tests tooling

Projects

None yet

1 participant