Skip to content

requiredWhen(与字段级 min/max)是转换门,validations[] script 是真不变量 —— 边界是刻意的、已被双驱动实测,但平台没有任何一处教它 #13879

Description

@huangyiirene

按维护者指示上行立卡(2026-08-31,hotcrm#1069 决裁,逐字:「1069 如果是平台的问题就提交平台卡片。」)。

实测事实(hotcrm#1069 + 其工具对比评论,双驱动同判,可运行复现在 hotcrm test/quote-contact-required-when.test.ts 与 test/quote-discount-ceiling.test.ts)

  • requiredWhen 只在「谓词变为真的那次写入」或「清空字段的那次写入」校验。规则生效前已处于门内状态的行:改任意字段、状态在门内推进(presented → accepted)全部放行 —— 而 accepted 在规则自己的条件里。对照:draft → presented 拒、生而 presented 的 INSERT 拒、清空字段拒 —— 边触发精确成立;
  • 字段级 max/min 同侧:存量 90 的折扣在 60 上限下,改无关字段、推进状态均放行(修复性写入亦放行);
  • validations[] 的 type:'script' 规则是真不变量:每次写入对合并后记录重评,存量违规行在任何编辑上被拒、修复性写入放行 —— 冻结而不砖死。

两种语义都是刻意的,⛔ 本卡不要求改任何一种:「只校验所写内容」使新增规则不砖存量(#1017 裁决要求的 no-silent-breakage);「校验合并记录」提供真不变量。缺的是平台没有任何一处写明这条边界与工具选择 —— 「required when X」的自然读法是不变量,AI 作者会静默选错工具:hotcrm 三条散文读作不变量的规则(win/loss_reason、lead 重复回指、quote 联系人)全部用了转换门工具,零信号。

交付物(平台侧文档/教学,statement of the boundary)

  1. spec docblock:requiredWhen 键与字段级 bounds 的声明处,各一段写明转换语义(以上表为例);validations[] script 的声明处写明不变量语义与「冻结不砖死」行为;
  2. 发布 skills(教应用作者那套):工具选择行 ——「要不变量(X 永不得超过 Y)⇒ validations[] script,代价是存量违规行冻结至修复;要转换条件(到达状态 S 时 X 须已备)⇒ requiredWhen / 字段 bounds,代价是先于规则的存量行放行」。受管面照旧;
  3. 可选(先估价):evaluateValidationRules 处的实现注记互链。

⛔ 范围外:改语义;「不变量捷径」类新 affordance(有拉力另立设计卡)。

Refs: hotcrm#1069(测量原卡)· hotcrm#1017(转换语义的受益方)· 今日应用仓三原则 objectstack#13848(应用不自建平台级机制,平台统一教)。

Activity

  1. claude commented on Sep 3, 2026

    @claude
    Contributor

    Claim: os-dev subagent seat (execution).

    • Session: session_017RbbUMnxkUnWhE4j94v8FE — os-dev subagent of that PM session, dispatched by the domain:spec seat (post [PM seat] domain:spec — 🟢 os-warren · session_019G5ZnRrTMiwcHG6iZM19zf #6017) under the PM claim above.
    • Branch: claude/issue-13879-required-when-transition-gate-docblocks (pushed empty at 06:57Z as the write-routing probe, before the first edit)
    • Worktree: /home/user/objectstack-issue-13879, cut from origin/main 2263ca4d6
    • Clause-②: no
    • Scope: deliverable 1 only — the .describe() / docblock text at Field.requiredWhen, the field bounds (min / max / minLength / maxLength), the inline-grid column requiredWhen, and the validations[] script rule in packages/spec, plus the regenerated reference pages and one schema-reachable pin. No semantic change to either mechanism. packages/objectql/src/validation/rule-validator.ts is not touched (PR chore(lint): delete inert eslint-disable family comments in package sources #14801 is open on it); the spec text names evaluateValidationRules by symbol instead. Deliverable 2 (the published skills/** tool-choice line) is not in this PR — the wording is proposed in the PR body for the domain:skills follow-up card.
    • Assignee: left as the PM set it; never written by this seat.

    Generated by Claude Code


    Generated by Claude Code

  2. claude commented on Sep 3, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 13879,
      "status": "done",
      "branch": "claude/issue-13879-required-when-transition-gate-docblocks",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14889",
      "premise_still_valid": true,
      "head_sha": "e8cd5aa21",
      "summary": "Deliverable 1 only, as dispatched: the transition-gate vs invariant boundary is now stated in the spec's own contract text at every declaration, with each half naming the other tool. `Field.requiredWhen` (a transition gate), `min` / `max` / `minLength` / `maxLength` (the written-value-only class), the inline-grid column `requiredWhen` (presentation only — nothing on the write path reads `inlineColumns`), and the `validations[]` `script` rule's `condition` plus a new module-docblock section in `validation.zod.ts` that carries the whole boundary, the price of each choice, and the implementing symbols (`evaluateValidationRules`, `validateRecord`). No semantic change: no schema, accepted key set, validator or runtime behaviour moves, and `packages/objectql/src/validation/rule-validator.ts` is not touched (PR #14801 is open on it) — the spec names the symbol instead. One schema-reachable pin (18 assertions) reads the text THROUGH the schema, not by source grep, and the four generated reference pages the describe strings feed were regenerated with the tooling. H1 needed one correction the text follows: `requiredWhen` is NOT 'the written value only' — it is evaluated against the MERGED record on every write, exactly like a script rule, and what makes it a transition gate is the ADR-0113 non-regression branch beside it (refuse iff merged violates AND the pre-write record complied). The bounds are the written-value-only class for a different reason (`validateRecord` iterates the UPDATE payload). H2, H3, H4, H5 held as hypothesised. The card body is intact (it ends with the Refs line naming hotcrm#1069, hotcrm#1017, objectstack#13848) and the assignee was already set by the PM; this seat never wrote it.",
      "tests": "All heavy runs through scripts/pm/os-verify-lock.sh; exit codes captured before any pipe; verdicts quoted from each gate's own line. Union run at HEAD e8cd5aa21 (tree clean, nothing committed after it). (1) PREMISE MEASUREMENT — scratch vitest over evaluateValidationRules / validateRecord, 12 cases (requiredWhen: INSERT-in-gate REFUSED, draft->presented REFUSED, clear-cell REFUSED, in-gate move presented->accepted PASSES, unrelated edit PASSES, repair PASSES; bound max: out-of-bound write REFUSED, unrelated edit while a stored 90 stands PASSES, repair PASSES; script: unrelated edit while a stored 90 stands REFUSED, repair PASSES, violating INSERT REFUSED) — 'Tests 12 passed (12)', and alongside the two existing objectql validation suites 'Test Files 3 passed (3) / Tests 293 passed (293)'. The scratch file was deleted before the commit, so this PR adds no test to packages/objectql; the finding below records the one case the engine suite does not pin. (2) NEW PIN alone — 'Test Files 1 passed (1) / Tests 18 passed (18)'. (3) REVERSE VERIFICATION, expected direction RED, fix committed first: replaced 'A TRANSITION GATE, not an invariant:' with 'A conditional requirement:' in field.zod.ts. Mutation proved ON DISK by grep counts anchored on both texts (injected=1, removed-text-remaining=0) and by blob hash 294c9e12 -> 45e5bcb6; the pin went RED with exactly 2 failures (the class assertion and the control), 16 green. Restored via `git checkout HEAD -- ABSOLUTE_PATH` under a trap on EXIT/INT/TERM, proved by blob hash back to 294c9e12 (equal to the HEAD blob, non-empty), `git diff HEAD` empty and porcelain empty; the following spec build reproduced the same build-input hash 07476395bc9261fa. No dist is involved in this ablation — the pin imports ./field.zod as a same-package relative source path that vitest transforms from src, not through package exports into dist, so no rebuild leg applies; the rebuild that WAS needed is recorded under gates below. (4) AFFECTED PACKAGE — `pnpm --filter @objectstack/spec test`: 'Test Files 458 passed | 1 skipped (459), Tests 12294 passed | 1 skipped (12295)', VERDICT command-exit 0. `pnpm --filter @objectstack/spec typecheck`: VERDICT command-exit 0, 'check:test-typecheck: OK'; the new test file is really in that program — `tsc -p tsconfig.test.json --listFiles` lists it (1 hit) and 0 of the 262 pre-existing ledgered errors name it. (5) `pnpm lint` (eslint . --no-inline-config, WHOLE repo): exit 0, no findings — no narrowing claimed, so the three narrowing evidences are not owed. (6) `pnpm check:nul-bytes`: 'OK (scanned 8074 text file(s) ... no raw ASCII control bytes)', plus a manual control-byte grep over the 8 changed paths (no hits). (7) Regeneration: `check:generated` named exactly one stale artifact (content/docs/references/**) and `--fix` regenerated only it (gen:docs); every changed line in the four .mdx files is one of the new sentences, verified by filtering the diff.",
      "gates": "Re-derived on the ACTUAL changed paths with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the worktree root (no paths passed; the script took the 8-path change set from the merge base 2263ca4d6, three-dot). 72 commands, each run with output redirected first and the exit code captured before any pipe: 68 GREEN, 0 RED, 4 NOT MEASURED. NOT MEASURED, all four for the same reason (this container has no whole-workspace build; CI's Build Core performs one): check-dev-prereqs.mjs ('The workspace is not built - 1 unmet precondition, not a list of problems'), check-test-completeness.mjs exit 3 ('running the family locally, record this gate as NOT MEASURED'), check:dual-build-cjs-loads exit 3 ('PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured'), check:type-check-debt exit 3 ('--re-measure cannot run ... NOT a pass and NOT a finding'). DECLARED NARROWING: I did not run a whole-workspace build to close those four; I built @objectstack/spec, the @objectstack/objectql dependency closure and the @objectstack/client-react closure only. Seven gates that read packages/spec/dist went red on the first pass purely on MTIME — the ablation's restore rewrote field.zod.ts after the build, so dist read as older than src; all seven are green after rebuilding: check:api-surface, check:browser-reachable-entries, check:dual-source-exports, check:entry-nameability, check:exported-any, check:generated, check:skill-examples (the last also needed the client-react closure, after which '256 prose examples type-check across 3 surface(s)'). The always-runs CI tail is not in the derived list; CI on the draft PR is the final reading. The PM's pre-derived gates-13879.txt was treated as a clue only, per os-dev.md.",
      "line_budget": "n/a — no skills/** path in the diff. Deliverable 2 (the published skills tool-choice line) is deliberately not in this PR; the proposed wording is in the PR body under 'Proposed skills tool-choice line (for the follow-up card)' for the domain:skills lane.",
      "files_changed": [
        "packages/spec/src/data/field.zod.ts (4 describe strings: requiredWhen, min, max, minLength, maxLength — 5 keys, and the inline-grid column requiredWhen)",
        "packages/spec/src/data/validation.zod.ts (module docblock section + the script rule's condition describe)",
        "packages/spec/src/data/validation-boundary-description.test.ts (new — the schema-reachable pin, 18 assertions)",
        ".changeset/validation-transition-gate-vs-invariant-boundary.md (patch, @objectstack/spec)",
        "content/docs/references/data/field.mdx (regenerated)",
        "content/docs/references/data/object.mdx (regenerated)",
        "content/docs/references/data/validation.mdx (regenerated)",
        "content/docs/references/system/migration.mdx (regenerated)"
      ],
      "deviations": [
        "My own Claim comment (5522465646) was posted AFTER the code was written, not before it. The claim-first requirement was already satisfied by the PM's claim 5521764565 on the card (os-dev.md rule 2), and the empty branch was pushed as the write-routing probe at 06:57Z before the first edit, but the dispatch asked for my claim comment up front and I posted it late. It carries the literal 'Clause-②: no' line the carriers check reads.",
        "Declared narrowing: no whole-workspace build, so 4 of the 72 derived gates report PREREQUISITE NOT MET rather than a verdict (listed in `gates`). None of the four is a red and none reads on this diff; CI runs all of them on a built tree.",
        "The card's H1 phrasing ('evaluated on the written value only') does not describe requiredWhen — it describes the bounds. The docblocks state each mechanism as the code implements it rather than as one shared sentence; the correction and its evidence are in the PR body's measurement table and in the paragraph after it."
      ],
      "channels": {
        "mcp_calls": "0 — zero MCP GitHub calls for the whole run.",
        "detail": "REST probe green in this session (GET /repos/objectstack-ai/objectstack/issues/13879 -> HTTP 200), so: card body + all comments read from the public-repo zero-quota page payload; dedupe increment, claim comment, draft PR create, PR body PATCH, PR read-backs and the finding all through repo-scoped REST; git for everything local. No issue search was used — the PM's 06:09Z board reading was taken as given and only the increment since then was re-checked (state=all&since=2026-09-03T06:09:00Z, 78 items, single page, no duplicate of the finding)."
      },
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #14891 (unassigned, label `finding`): the `validations[]` `script` behaviour this PR now states as contract — a stored violation refused on an UNRELATED edit, i.e. the case that distinguishes an invariant from requiredWhen's transition gate — has no pin in packages/objectql/src/validation/rule-validator.test.ts, while the requiredWhen side's mirror case ('legacy rows rest') is pinned. Measured green in the scratch run; two `it` cases in the existing `script / cross_field predicates` block would close it. Not fixed here: PR #14801 is open on rule-validator.ts and this PR is scoped to packages/spec."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions