Skip to content

fix(lint)!: publish refuses a text-vs-number comparison in a validation rule (or another fail-closed predicate) instead of warning, then refusing every write - #29923

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

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-ytyozv

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #29904

Clause-②: no (narrowing) — the author-time gate (os build, os validate, the object save door and the package publish) refuses a type-mismatch it used to pass with a warning, at the predicate sites that fail closed. Nothing is widened.
Metadata-protocol: narrowed
Skills-docs: updated here — content/docs/data-modeling/formulas.mdx (the build-time validation table's type-soundness row said warning for every site) and the MCP skill text in packages/mcp/src/skill-md.ts; skills/** teaches no text-vs-number comparison and no type-soundness severity (grep for the verdict words and record.X > N on text fields: 0 hits), so no skill or eval moves.

维护者速读

  • 改了什么:校验规则(以及 requiredWhen / readonlyWhen、选项 visibleWhen、hook 条件)里"文本字段和数字比大小"(如 record.name > 5)现在发布时直接拒绝(error),公式字段仍只给 warning。
  • 为什么改:这些位置求值失败会拒绝写入;以前发布只提示"会得到 null",发布成功后该对象的每次新建和更新都被拒,一个笔误就让整张表录不进数据。
  • 风险与回滚:收窄发布门,已存在的此类规则下次发布会被拒(它们本来就让写入全部失败);回滚即 revert 本 PR,无数据迁移。
  • 先看:packages/formula/src/validate.ts(fail-closed 分支与文案)、packages/lint/src/validate-expressions.ts(FAIL_CLOSED 站点表及各调用点)、packages/mcp/src/mcp-http-tools.ts(validation 站点)。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/publish-type-mismatch-fail-closed.test.ts,预期 3 条全过:校验规则被拒、公式仍发布带 warning、可空孪生照旧被拒。
  • 需要你做的:无,席位落地。

What changes

  • @objectstack/formula. ExprSchemaHint gains faultOutcome?: 'fail-closed'. The validator cannot tell a validation rule's predicate from a flow condition's, so the caller that knows the site declares it. With it, type-mismatch is graded an error whose message says what really happens (the write, or the operation a hook guards, is refused) and how to compare like with like; the refusal's params carry outcome: 'fail-closed'. Without it, the warning and its "evaluates to null" text are unchanged.
  • @objectstack/lint. One constant, FAIL_CLOSED, with the site ledger in its docblock, passed by check() at exactly the fail-closed sites: validation rule condition / when and nested then / otherwise rules, field requiredWhen / readonlyWhen, option visibleWhen, hook condition. These are the sites where the nullable-comparison twin is already an error with the 'fail-closed' outcome (plus readonlyWhen and option visibleWhen, see below).
  • @objectstack/mcp. validate_expression with site: 'validation' passes the same outcome, so the tool an agent self-checks with answers what publish will. Its description and the served skill text say so.
  • @objectstack/spec. Upgrade guide entry validation-type-mismatch-refused (semantic: no mechanical rewrite). ADR-0087 disposition registered validation-type-mismatch-refused.
  • Docs. content/docs/data-modeling/formulas.mdx: the type-soundness row's severity and fix.

Which roles fail closed (measured by reading each evaluator)

site runtime on a fault this PR
validation rule condition / when, nested rules rule-validator.ts checkPredicate / checkConditional: "write rejected" (#4649) error
field requiredWhen rule-validator.ts: refuses the write (ADR-0137 D2) error
field readonlyWhen isReadonlyWhenLocked: throws ValidationError on every fault but the unbound-root carve-out (ADR-0137 D2) error
option visibleWhen evaluateOptionVisibility: refuses a write that picks the option (ADR-0137 D2, #22402) error
hook condition hook-wrappers.ts: aborts the operation (#4775) error
formula field expression reads null warning (text true)
flow start / edge / decision condition the run fails; the write that fired it lands (run-fails) warning, left out: not a write gate. Open question in the report
field visibleWhen / settledWhen, conditionalFormatting, field group visibleWhen, action visible / disabled / pressed, app landing visible renderer shows, hides or styles; never refuses a write warning
sharing rule condition; RLS / permission predicates compiled to a query filter (compileCelToFilter / isSupportedRlsExpression), not CEL per row not judged by this verdict

The evaluator's fail-closed behaviour is untouched.

The conversion the message names, measured on the evaluator (ExpressionEngine.evaluate, values "abc" / "7" / null): record.name > 5 → FAULT / true / FAULT; size(record.name) > 5 → false / false / FAULT; double(record.name) > 5 → FAULT / true / FAULT (validates clean, faults on non-numeric text, so the message says so); int(...) same as double.

Tests

  • packages/lint/src/validate-expressions.test.ts: the old pin "warns on a validation predicate ordering a text field against a number" is the behaviour this card retires; it is replaced by five pins: the validation condition refusal (path objects[0].validations[0].condition, message), the formula warning, one refusal per other fail-closed site (when, nested then, requiredWhen, readonlyWhen on a boolean, option visibleWhen, hook condition), the warning kept on field visibleWhen and action visible, and the nullable twin unchanged.
  • packages/formula/src/validate.test.ts: faultOutcome → error with code/params, boolean arm, and the two conversions validate clean.
  • packages/mcp/src/mcp-validate-expression.test.ts: site: 'validation' → error.
  • packages/objectql/src/publish-type-mismatch-fail-closed.test.ts (new): the real protocol saveMetaItem (draft) + publishPackageDrafts over a real ObjectQL: refused with expression-invalid / error at …validations[0].condition; formula publishes with the warning advisory; the nullable twin still refused.
Package suites (at d5b798c, under os-verify-lock; the final commit 1dfad41 changes only the new objectql test's driver double)
formula test     Test Files 64 passed (64)    Tests 1629 passed (1629)
lint test        Test Files 192 passed (192)  Tests 7949 passed (7949)
mcp test         Test Files 55 passed (55)    Tests 660 passed (660)
spec test        Test Files 808 passed (808)  Tests 24115 passed | 1 todo (24116)
spec test:repo   Test Files 89 passed (89)    Tests 1524 passed (1524)
objectql new file  Tests 3 passed (3)
typecheck: formula, lint, mcp, spec, objectql — exit 0
Ablation (old severity put back in the fail-closed branch, formula rebuilt, marker proven in dist)

Mutation: severity: 'error' → severity: ['warning', 'ablated-29904'][0] as 'warning' in the fail-closed arm; ablation-replace anchor 1 → 0, ablation-dist-preflight marker present in dist/index.js and dist/index.mjs.

lint     × refuses a validation predicate ordering a required text field against a number (#29904)
           AssertionError: expected { …(5) } to match object { severity: 'error', …(1) }
         × refuses the type mismatch at every other fail-closed site (#29904)
         Tests 2 failed | 3 passed
objectql × refuses `record.name > 5` on a required text field in validations[].condition
           AssertionError: expected 'published' to be 'refused'
formula  × errors on the same comparison where the caller declares the site fail-closed (#29904)
mcp      × errors on a text field ordered against a number at a validation site (#29904)

The nullable-twin pins stayed green under the mutation (they are the control). Restore: blob back to HEAD 6fcce907, git diff HEAD empty, formula rebuilt, preflight --absent clean on all 6 built files; lint 5/5 and objectql 3/3 green again.

Real boot (showcase `pnpm dev -- --fresh`, full build at d5b798c, as admin, writable package com.qa.tmm)
[1] PUT /api/v1/meta/object/tmm_lead?mode=draft  (required text `name`, rule `record.name > 5`) → 200
    POST /api/v1/packages/com.qa.tmm/publish-drafts → 200 outcome=refused
      rule=expression-invalid severity=error path=objects.tmm_lead.validations[0].condition
      message: type mismatch `string > int` — `record.name` holds text but is used with `>` against a number.
      CEL has no such operation, so wherever the value is not numeric text (an empty value included) the
      expression faults, and here an expression that cannot be evaluated fails closed: the write (or the
      operation a hook guards) is refused, not skipped. Compare like with like: … `size(record.name)` for its
      length, `double(record.name)` for its value (which itself faults on text that is not a number).
[1b] same rule as `size(record.name) > 5` → publish 200 outcome=published
    POST /api/v1/data/tmm_lead {"name":"abc"}     → 201
    POST /api/v1/data/tmm_lead {"name":"abcdefg"} → 400 VALIDATION_FAILED rule_violation "bad"   (the rule now judges)
[2] formula `big = record.name > 5` → publish 200 published, advisory expression-invalid severity=warning "… silently evaluates to null …"
[4] nullable twin `record.qty > 1000` → publish 200 outcome=refused, expression-invalid error "… declares as nullable …" (unchanged)

Before (card, main 589049b0): the same [1] published with a warning, and every POST /data answered 400 "could not be evaluated … write rejected". The draft save answers 200 for both this rule and the twin, as before (the save door keeps #4463 D1).

Acceptance notes

  • Validation rules of severity: 'warning' / 'info'. The runtime logs an unevaluable advisory rule instead of refusing; the gate refuses the comparison on every validation rule regardless, exactly as the nullable twin does. Kept for parity with the twin; the message's "refused" clause is exact for error rules.
  • readonlyWhen and option visibleWhen are fail-closed sites the twin does not cover (its readonlyWhen exclusion is ruling #4649 的「记录对已声明字段全量」只落在两个接缝上 —— 另外三处求值仍是稀疏绑定 #4953 clause 3, about the has() prescription, which this verdict does not give). Included because their evaluators refuse the write on a fault.
  • Serial check. At 2026-11-03T11:40Z and again at 12:55Z (before opening), none of the open PRs touches packages/formula/src/validate.ts, packages/lint/src/validate-expressions.ts or packages/mcp/src/mcp-http-tools.ts (per-PR file lists read).

Verification

All at the final head 1dfad41.

  • node scripts/pm/dispatch-gates.mjs --commands: 106 derived families, each run, all exit 0; --ran reconciliation: "106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN". The first round (at d5b798c) had one red, check:objectql-double-limit (the new test's copied find double ignored limit); fixed in 1dfad41, and the whole list was run again.
  • pnpm lint (eslint . --no-inline-config): exit 0.
  • Full dogfood (pnpm --filter @objectstack/dogfood test, after a full turbo run build, 119/119 tasks): Test Files 410 passed | 3 skipped (413), Tests 3379 passed | 28 skipped (3407); lock VERDICT command-exit 0.
  • check-adr-0087-registration --base origin/main: "registered validation-type-mismatch-refused (new here)".

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzhUVCv2vcJJepWyHQQ

…sed predicate

A validation rule (and a field requiredWhen / readonlyWhen, an option
visibleWhen on the write path, a hook condition) fails closed: a
predicate that cannot be evaluated refuses the write. `record.name > 5`
on a text field faults on every value that is not numeric text, so it
published with a warning that it "evaluates to null" and then refused
every write. The shared validator now takes the site's fault outcome
(`ExprSchemaHint.faultOutcome`), the lint gate passes it at exactly the
fail-closed sites, and there the type-mismatch verdict is an error
worded for a refused write. Formula fields keep the warning. The MCP
validate_expression tool's validation site answers the same.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzhUVCv2vcJJepWyHQQ
…de entry

The semantic migration the narrowing owes: the surface, the like-with-like
rewrite and why there is no mechanical one.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzhUVCv2vcJJepWyHQQ
…ter its filter

check:objectql-double-limit refuses a new limit-blind find double.

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

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/formula, @objectstack/lint, @objectstack/mcp, @objectstack/spec, touching 15 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/ai/skills-reference.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses), requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/automation/flows.mdx (via validateExpression (symbol, a top-level function))
  • content/docs/automation/hook-bodies.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/data-modeling/field-types.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses), requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/data-modeling/fields.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses), requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/data-modeling/formulas.mdx (via validateExpression (symbol, a top-level function), readonlyWhen (literal, a string literal in runStackExpressionPasses), requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/data-modeling/validation-rules.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses), requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/deployment/cli.mdx (via requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/deployment/validating-metadata.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses), requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/kernel/contracts/data-engine.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/permissions/system-context.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses), requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/protocol/objectql/security.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/protocol/objectui/concept.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses), requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/protocol/objectui/layout-dsl.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses), requiredWhen (literal, a string literal in runStackExpressionPasses))

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

  • content/docs/releases/implementation-status.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses), requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/releases/v15.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses), requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/releases/v16.mdx (via requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/releases/v17/17-0.mdx (via requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/releases/v17/17-1.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/releases/v17/17-4.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/releases/v17/17-5.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses), requiredWhen (literal, a string literal in runStackExpressionPasses))
  • content/docs/releases/v18/18-1.mdx (via readonlyWhen (literal, a string literal in runStackExpressionPasses), requiredWhen (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 changed file(s) yielded no anchor (packages/mcp/src/skill-md.ts) — pages documenting those are invisible to this run
  • 4 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 — 174 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 a04e9d76c13363280bdd86950603f0e0e6deb6dd → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 a04e9d76c13363280bdd86950603f0e0e6deb6dd → 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: 1dfad411f3605ce8d35b358524e1ec6393bc5d67
Local-runs: none
Scope: packages/spec/src/migrations/entries/semantic/18.validation-type-mismatch-refused.ts, packages/formula/src/expression-refusal.ts, packages/formula/src/validate.ts, packages/formula/src/validate.test.ts, packages/lint/src/validate-expressions.ts, packages/lint/src/validate-expressions.test.ts, packages/mcp/src/mcp-http-tools.ts, packages/mcp/src/mcp-validate-expression.test.ts, packages/mcp/src/skill-md.ts, packages/objectql/src/publish-type-mismatch-fail-closed.test.ts + packages/formula/src/cel-engine.ts (firstTypeMismatch, UNSOUND_OVERLOAD_RE, coercionFor, NUMERIC_STRING_RE), packages/formula/src/expression-refusal-codes.test.ts, packages/objectql/src/validation/rule-validator.ts (checkPredicate, checkConditional, evaluateValidationRules requiredWhen arm, isReadonlyWhenLocked, evaluateOptionVisibility, the severity loop), packages/objectql/src/hook-wrappers.ts (wrapDeclarativeHook condition gate), packages/objectql/src/engine.ts (formula reads null), packages/lint/src/validate-null-guards.ts (NullGuardOutcome, surface ledger), packages/metadata-protocol/src/protocol.ts (saveMetaItem, assertRuntimeAuthoringRules), packages/spec/src/migrations/entries/README.md, packages/spec/src/migrations/types.ts, scripts/check-adr-0087-registration.mjs, .github/workflows/pr-automation.yml

Face: narrowing (Clause-②: no (narrowing) on PR body and changeset) + published spec path. 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

  1. @objectstack/formula ExprSchemaHint.faultOutcome?: 'fail-closed': new optional key on an exported interface; absent reproduces the previous call byte for byte. Additive, minor. Right.
  2. ExpressionRefusalParams['type-mismatch'].outcome?: 'fail-closed': new optional param, present exactly when the error arm fires. Right as a type change; see ④-A.
  3. Accept-set narrowing at the author-time gate: type-mismatch moves from warnings[] to errors[] at fail-closed sites, reaching os validate / os build / os lint, the active object save door and publishPackageDrafts (assertRuntimeAuthoringRules 422); draft saves stay ungated ([runtime/metadata] 作者时规则只存在于 CLI:Studio/REST/MCP 的运行时授权面是第四扇门,26 条规则一条不跑——#4409 修完后最大的敞口 #4463 D1). Sites: validation condition / when and every nested predicate, field requiredWhen / readonlyWhen, option visibleWhen, hook condition (single-object and targets). Right, each verified against its evaluator in ④.
  4. Not narrowed: formula expression (reads null), flow conditions, field visibleWhen / settledWhen, field-group and action predicates, conditionalFormatting, sharing rules (compiled to a filter). Right.
  5. @objectstack/mcp validate_expression: site: 'validation' now answers what publish answers; other sites untouched. Right, carried by the BREAKING changeset.
  6. @objectstack/spec: one new semantic D3 entry, id matches filename, shape matches SemanticMigration, no index edited (registry generated from the directory). Right.
  7. No spec zod schema, protocol schema or skills/** change. Right.

② Semver level

formula, lint, mcp, spec all minor; fix(lint)!: summary; **BREAKING** banner; Clause-②: no (narrowing) and Metadata-protocol: narrowed identical on PR body and changeset; disposition adr-0087: registered validation-type-mismatch-refused with a FROM → TO section (a FROM → TO line contradicts not-required, so registered + a D3 entry is the honest disposition; precedent #28944). objectql gets only a test file, correctly not bumped. Check Changeset (runs the ADR-0087 registration gate), Governed Surface Queue Guard, PR-body guards, dispatch-gates lint, Dogfood Regression Gate and Spec property liveness: success at record time; Build/Test families still in progress, none red.

③ Boundary flags

  1. Dev open question (flow conditions carry the false "evaluates to null" warning): confirmed on the tree; validate-null-guards.ts documents that surface as run-fails. Follow-up card, filed and linked on publish gate: a validation rule comparing a text field with a number (record.name > 5) publishes with only a warning that says it "evaluates to null", then every write to the object is refused #29904 before the queue, not this PR: the twin treats flows as a distinct outcome with its own wording, grading it error widens the narrowing beyond write-refusing roles, and the author sees a failed run rather than a bricked object.
  2. Advisory-severity validation rules (severity: 'warning' | 'info'): an unevaluable advisory rule is logged, not refused (objectql(validation): an unevaluable severity:'warning' rule logs "write rejected: … fails closed" twice while the write is accepted 201 and persists #24782), so the error text's "the write … is refused" is false for them, yet the gate refuses them. The nullable twin does the same. Accepted for parity; should-fix candidate for the same follow-up card.
  3. readonlyWhen and option visibleWhen are not twin-covered; including them is correct (both refuse on a fault since ADR-0137 D2; this verdict prescribes no has(), so #4649 的「记录对已声明字段全量」只落在两个接缝上 —— 另外三处求值仍是稀疏绑定 #4953 clause 3 does not bind).
  4. Assignee/review steps and the signed trailer are harness matters, not reviewed here.

④ Design judgment

Card scenario. record.name > 5 on a required text field in validations[].condition: on main a warning advisory, then every write 400; at the head check(…, FAIL_CLOSED) → error → publish refused at objects[N].validations[0].condition. The new objectql door test drives the real saveMetaItem + publishPackageDrafts and pins refused / formula-still-published / twin-unchanged.

One decision, one place. Role→severity is decided only in lint's FAIL_CLOSED ledger (nine check() call sites), with the MCP site map as the one declared mirror; the formula validator never guesses the site.

Per file: validate.ts holds (error arm after date-arith, before the warning return; message claims match coercionFor / NUMERIC_STRING_RE); validate-expressions.ts holds (every ledger row verified against its evaluator; display-only predicates left at warning); its tests retire exactly the behaviour the card retires and add per-site, formula, display and twin controls; MCP files hold (other sites' hints byte-identical); the objectql door test holds; the D3 entry holds (nit B).

FROM → TO in this CEL dialect: size(record.name) > 5 ✓, double(record.name) > 5 ✓ (faults on non-numeric text, as the message says), record.active == true ✓ (equality excluded from UNSOUND_OVERLOAD_RE).

False positives probed: number/currency/percent/select/lookup/autonumber are dyn, so a mismatch fires only on declared text/boolean/date fields. The one accepted false positive, a text field always holding numeric text, is the card's ask, follows the twin's precedent, and the message names the rewrite.

Findings:

  • A (should-fix): expression-refusal.ts's contract ("a code names ONE message template") and expression-refusal-codes.test.ts's pins are strained: type-mismatch now has a second template chosen by params.outcome, and 'fail-closed' is not a substring of its message. CI is green because SWEEP_SCHEMAS carries no faultOutcome; no consumer keys on the code today. Fix in the follow-up (pin the case, or give the arm its own code).
  • B (should-fix, nit): the D3 entry's acceptanceCriteria says a create "answers 200"; the data door answers 201.
  • C (should-fix → follow-up card): ③-1 and ③-2.

No must-fix. The narrowing takes away no declared capability: everything it refuses already refused every write at runtime (or, for numeric text, is named with its exact rewrite), and it makes the spec's own fail-closed refusal real at author time.

Implemented-by: claude/project-thread-ytyozv
Reviewed-by: contract-review subagent of session_01WGufzibcN9oHa8kDbTTnL5 (CONTRACT_REVIEW_TIER)

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

席位核对

Verdict: ACCEPT at head 1dfad411f3605ce8d35b358524e1ec6393bc5d67 · 2026-11-03T13:13Z

  • Shape: 12 files (formula, lint, mcp, a spec D3 migration entry, an objectql door test, formulas.mdx, one changeset), +519/−26. No governed path ⇒ not Tier H. Faces: narrowing (Clause-②: no (narrowing)) + a published spec path ⇒ CONTRACT_REVIEW_TIER review: PASS 6604729050 at this head, run in-seat as an isolated reviewer.
  • Premise measured: real boot: publish-drafts refused with expression-invalid error at objects.tmm_lead.validations[0].condition; the size() rewrite publishes and the rule then judges writes (201 / 400); the formula control still publishes with the warning; the nullable twin unchanged; draft saves 200 as before.
  • Gates: 106/106 derived gates exit 0 at this head, pnpm lint 0, full dogfood 410 files green (dev report 6604395784). Ablation (old severity back): the lint, objectql door, formula and MCP pins go red, the twin control stays green; restored green. PR check-runs: landing waits for every check on this head to be green or an expected skip.
  • Accepted deviations: harness-pinned branch claude/project-thread-ytyozv; ADR-0087 disposition registered with a semantic D3 entry instead of not-required (the gate refuses not-required beside a FROM → TO line; precedent formula: the date-arithmetic build gate passes bare date subtraction (record.end_date - record.start_date, today() - record.d), which reads null or {} at runtime #28944), so @objectstack/spec moves minor. The signed trailer was appended to the body by the seat through the relay.
  • Out of scope, where it goes: filed 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 — flow conditions' false "evaluates to null" text (run-fails), advisory-severity validation rules, the type-mismatch two-template strain on the refusal-code contract (review ④-A), and the D3 entry's "200" nit (④-B). Skills-docs: updated here checked: formulas.mdx row and the MCP skill text move; skills/** teaches no such comparison.
  • 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 3, 2026 13:25
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 4c2cd29 Nov 3, 2026
70 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-ytyozv branch November 3, 2026 13:48
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