From 1917deeb99d15a043b0adc75b101c7c73428597d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 00:02:32 +0000 Subject: [PATCH] =?UTF-8?q?docs(core):=20fieldRules=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=A4=B4=E6=94=B6=E7=AA=84=20readonly=20fail-open=20=E7=9A=84?= =?UTF-8?q?=E3=80=8C=E4=B8=8E=E6=9C=8D=E5=8A=A1=E7=AB=AF=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E3=80=8D=E6=96=AD=E8=A8=80=20(#3828)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模块头把客户端 readonly fail-open 写成「matching the server, which logs and allows the change through」。这句在写下时为真,但自 objectstack#4889 起对**未 绑定根**这一类已不成立:`readonlyWhen` 因为引用了本次写入没有绑定的 scope 根 (`parent.status == 'paid'` 而手里没有 master-detail 头)而 fault 时,服务端是 fail-CLOSED —— `isReadonlyWhenLocked` 记日志后 `return true`(LOCKED), `stripReadonlyWhenFields` / `...Multi` 随即把该 key 从 UPDATE payload 中删除。 客户端这一侧本身没错(fallback: false,字段仍可编辑),错的是「一致」这半句: 两端在这一类上方向相反,而注释把它描述成同向。 收窄后的正文点明三件事:两端在多数 fault 一致、未绑定根一类相反;相反是按 framework 的 ADR-0057 D10(server enforces, client is courtesy)刻意为之,客 户端不猜「锁死」;以及调用方后果 —— 表单可编辑、保存报成功、值静默不落库,排 障要往服务端锁死的方向查(服务端 `treating the field as LOCKED` 警告 +写响应 的 `droppedFields`),而不是查客户端谓词。 `requiredWhen` 半句复核后仍准确(objectstack#4977 绑了 `parent` scope 但刻意 保留 fail-open 语义,两端都跳过),字段级 `visibleWhen` 服务端根本不评估,两处 均不动。下文引用的 "log and allow" 服务端消息标注为 GENERIC-fault 消息,以免 两段之后把刚收窄的结论又抹平。 零行为改动:纯 docblock,代码一行未动;`packages/core/src/evaluator` 测试前后 均 289 passed / 9 files;changeset 空 frontmatter 声明不发布(照 #3749 先例)。 Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- ...es-readonly-failopen-server-parity-3828.md | 34 ++++++++++++++++++ packages/core/src/evaluator/fieldRules.ts | 35 ++++++++++++++++--- 2 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 .changeset/fieldrules-readonly-failopen-server-parity-3828.md diff --git a/.changeset/fieldrules-readonly-failopen-server-parity-3828.md b/.changeset/fieldrules-readonly-failopen-server-parity-3828.md new file mode 100644 index 000000000..192ca020d --- /dev/null +++ b/.changeset/fieldrules-readonly-failopen-server-parity-3828.md @@ -0,0 +1,34 @@ +--- +--- + +Comment-only change, no behaviour and no authoring-surface change (objectui#3828). + +The module head of `packages/core/src/evaluator/fieldRules.ts` described the +client's readonly fail-open as "matching the server, which logs and allows the +change through". That was true when it was written and has not been true for one +whole fault class since objectstack#4889: a `readonlyWhen` predicate that faults +because it names a scope ROOT the write never bound (`parent.status == 'paid'` +with no master-detail header in hand) is fail-CLOSED on the server — +`isReadonlyWhenLocked` warns and returns TRUE (LOCKED), and +`stripReadonlyWhenFields` / `stripReadonlyWhenFieldsMulti` then delete that key +from the UPDATE payload. The client, correctly, keeps the same fault fail-OPEN. + +So the two ends point in OPPOSITE directions for that class, and the comment +described them as agreeing — which points a reader the wrong way on the one +symptom the divergence produces: the form renders the field editable, the save +reports success, and the value silently never lands. The narrowed text names the +divergence, cites the framework's ADR-0057 D10 (server enforces, client is +courtesy) as the reason the client does NOT follow the server here, and tells the +caller which end to debug (the server's `treating the field as LOCKED` warning +and the write response's `droppedFields`, not the client predicate). + +The `requiredWhen` half stays as it was, re-verified accurate: objectstack#4977 +bound the `parent` scope for those predicates and deliberately kept the fail-open +semantics, so an unevaluable requirement is skipped on both ends. Field-level +`visibleWhen` likewise — the server does not evaluate it at all. The "log and +allow" quote further down the docblock is now marked as the GENERIC-fault +message, so the narrowing is not undone two paragraphs later. + +No package is declared because nothing published changed: the diff is a docblock +only, every line of code untouched, and `packages/core/src/evaluator`'s tests keep +their count byte for byte (289 passed before, 289 passed after). diff --git a/packages/core/src/evaluator/fieldRules.ts b/packages/core/src/evaluator/fieldRules.ts index ea1dfc670..f0ea176c6 100644 --- a/packages/core/src/evaluator/fieldRules.ts +++ b/packages/core/src/evaluator/fieldRules.ts @@ -23,8 +23,33 @@ * CEL (mirrors the server's `toExpression`). Evaluation is *fail-open* for * visibility/required (a broken predicate must not hide a field or wrongly * block submit) and *fail-open* for readonly (a broken predicate leaves the - * field editable) — matching the server, which logs and allows the change - * through. + * field editable). + * + * Fail-open agrees with the server for MOST faults — but not for all, and the + * exception is the half worth knowing. Since objectstack#4889 a `readonlyWhen` + * predicate that faults because it names a scope ROOT the write never bound — + * `parent.status == 'paid'` with no master-detail header in hand — is + * fail-CLOSED on the server: `isReadonlyWhenLocked` warns and then returns + * TRUE, i.e. LOCKED (a declared lock is not waived merely because it could not + * be evaluated), and `stripReadonlyWhenFields` — plus + * `stripReadonlyWhenFieldsMulti` on the bulk path — DELETES that key from the + * UPDATE payload. This file keeps the same fault fail-OPEN. For that one class + * the two ends therefore point in OPPOSITE directions, deliberately: the + * framework's ADR-0057 D10 — server enforces, client is courtesy (framework + * numbering; this repo's own ADR-0057 is an unrelated document) — makes the + * server the authority, so the courtesy layer does not get to guess "locked" + * and grey out a field the server might have accepted. + * + * The caller-visible consequence, spelled out because the symptom is silent: + * the form renders the field EDITABLE, the user edits it, the save reports + * SUCCESS, and the new value never lands — the server dropped the key and kept + * the persisted one. Debug that toward the SERVER-side lock (its `… treating + * the field as LOCKED` warning, and the write response's `droppedFields`), not + * toward the client predicate, which did exactly what it was asked to. The + * other two halves carry no such carve-out: `requiredWhen` binds the same + * `parent` scope but deliberately kept fail-open semantics (objectstack#4977), + * so an unevaluable requirement is skipped on BOTH ends, and field-level + * `visibleWhen` is not a server concept at all. * * Fail-open is **loud**, not silent (objectstack#5149): a predicate that * cannot be evaluated — parse error, unbound identifier, engine fault — logs @@ -32,8 +57,10 @@ * reason, then still returns the caller's fallback. Without the warning a * broken predicate is indistinguishable from an absent one, so * conditional-visibility bugs survive inspection indefinitely. This is the - * client half of the server's "log and allow" convention (`readonlyWhen for - * 'x' failed to evaluate — change allowed through`). The *default* stays + * client half of the server's "log and allow" convention for the faults where + * the two ends DO agree (`readonlyWhen for 'x' failed to evaluate — change + * allowed through` — the GENERIC-fault message; the unbound-root fault above + * logs `treating the field as LOCKED` instead). The *default* stays * fail-open on purpose — flipping it is a shipped-behavior change tracked * separately in objectstack#5149 (appeal 1, undecided). */