Repository navigation
fix(objectql): bind previous to null on insert so a rule that reads it answers with its own message - #23971
Conversation
… it is evaluable A validation rule's CEL scope left `previous` unbound on insert, so any reference to it -- the guard `previous == null` included -- faulted `Unknown variable: previous`, and a violating insert was refused as `unevaluable` instead of with the rule's own message (`sys_user_permission_set.validity_window_order` is the measured case). `previousBinding` decides the binding once per evaluation -- `null` on insert, the prior row made total on update, unbound when an update's row is not in hand -- and `ruleScope` builds every rule predicate's scope from it, so no seam can drop it with a `?? undefined` again. A column read off a null `previous` still refuses, now naming the read and the guard to add. Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…lidation-previous-insert
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ba79d2ebc2d48ac572c812fbca6213b75355bdb1 && git checkout ba79d2ebc2d48ac572c812fbca6213b75355bdb1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 68ffa5dc1af1abeb012cd1ed7dfa18be4c07a8c4 a2863de0861e12bd401614b795fa57b69948a90b && git checkout -B drift-repro 68ffa5dc1af1abeb012cd1ed7dfa18be4c07a8c4 && git merge --no-ff a2863de0861e12bd401614b795fa57b69948a90b
node scripts/docs-audit/affected-docs.mjs --json 68ffa5dc1af1abeb012cd1ed7dfa18be4c07a8c4
|
…; Clause-② yes (widening) A declarative hook condition is now evaluated in the validation side's scope (`ruleScope`): `previous` is `null` on beforeInsert/afterInsert, the prior row on any other write that carries one, and unbound when an existing row's prior state is not in hand. Only the CEL binding changes; the engine's `ctx.previous` is untouched. A column read off the null `previous` still aborts the operation, now naming the read and the guard to add (`nullPreviousReadDetail`, shared with validation). The changeset becomes `minor` with `Clause-②: yes (widening)`: a rule or condition shaped `previous != null && ...` now admits inserts it refused with an `unevaluable` fault. The formulas page's `previous` row says `previous` is null on insert in validation, hook conditions and flows. Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…lidation-previous-insert
席位核对 · ✅ ACCEPT: PR #23971 at
|
Fixes #23910
Clause-②: yes (widening)
What changed
On insert,
previousis now bound tonullin validation rules and in declarative hook conditions. So a rule or condition that reads it decides the insert. The flow engine already gives a start condition on its create leg the same binding.Before, the insert path left
previousunbound. Any reference to it, including the guardprevious == null, faultedUnknown variable: previous. A violating insert was then refused (validation) or aborted (hook) asunevaluable, naming the CEL fault instead of the rule's message.sys_user_permission_set.validity_window_orderis the measured case.This widens the published accept set, hence
Clause-②: yes (widening)and aminorchangeset:previous != null && …("judge updates only") now ADMITS inserts that it refused before with theunevaluablefault.requiredWhen: previous == nullnow requires the field on insert, instead of refusing every insert.No shipped rule's or hook's verdict moves (census below).
Validation (
packages/objectql/src/validation/rule-validator.ts)previousBinding(mode, priorRecord, fields)decides the binding once per evaluation. On insert it returnsnull. On an update with the prior row in hand, it returns that row made total over the declared fields, as before.An update whose prior row is not in hand stays unbound. Examples are the bulk seam's
previous: nulland an update-mode preview that names no row.nullthere would answerprevious == nullwith "this is an insert", which is false. Unbound, a rule that readspreviousfails closed.ruleScope(record, previous, extra)builds the scope for every rule-evaluationExpressionEngine.evaluatein the file. That covers:script/cross_fieldcondition;conditional'swhen;requiredWhenand its ADR-0113 pre-check;visibleWhen;readonlyWhen, which is update-only and unchanged.The
previous ?? undefinedthat turned an explicitnullback into "unbound" is gone.nullridesextra, the bagbuildScopemerges onto the variable scope, becauseEvalContext.previousis typed for a row only.nullPreviousReadDetail. A column read off anullprevious(record.stage != previous.stage) still has no value on insert and still refuses. The refusal now namesprevious.KEYand gives the guards:previous == null || …judges an insert,previous != null && …skips one. Without this, the generic text would have said "a field this object declares, but its value was not supplied". All four refusal builders carry it.Hook conditions (
packages/objectql/src/hook-wrappers.ts)conditionis now evaluated inruleScope.pickPreviousPayloadreturnsnullonbeforeInsert/afterInsert. On any other write that carries a prior row, it returns that row, copied and made total as before. When an existing row's prior state is not in hand,previousstays unbound.ctx.previousis untouched. The engine's pre-image, which the after-hooks observe, is not changed. Only the CEL binding is.nullon insert still aborts the operation (hook 的condition求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775).unevaluableConditionErrornames the read with the samenullPreviousReadDetailsentence.null, and when it is ABSENT". It keeps the update-without-row reasoning.Docs (
content/docs/data-modeling/formulas.mdx)The
previousrow of the scope table now says the following.previousis the row before the update. It isnullon insert in validation, hook conditions and flows, soprevious == nulljudges an insert. A column read off it there has no value and faults. No other sentence on the page sayspreviousis unbound on insert.engine.tsis not edited. The insert call site hands the evaluatormode: 'insert', and the binding derives from that. The validate-only preview's insert mode judges the same way.Census (triage's instruction)
Validation rules reading
previousSearched at
b0d12e2e9acrosspackages/**(platform objects, plugins, services) andexamples/**, non-test, TS and non-TS metadata.validity_window_order(cross_field, error)packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts(previous == nullor a bound moved)and both bounds set andvalid_untilat or beforevalid_fromreason: unevaluable. Valid, one-bound or open window: admittedThat is the only shipped validation rule that reads
previous. Its verdict cannot move, for this reason:&&absorbs whenever another conjunct isfalse.trueon insert, and the rule answers the remaining conjuncts.So the same writes are refused and the same writes are admitted.
No field
requiredWhen/readonlyWhen/visibleWhenin shipped metadata readsprevious. The other*.object.tshits are prose: comments, field descriptions and action copy insys-metadata-history,sys-account,sys-member,sys-oauth-application,sys-oauth-consent,sys-two-factor,sys-user-preference,sys-setting-auditandsys-audit-log.Declarative hook conditions reading
previousSearched at
a2863de08acrosspackages/**andexamples/**.showcase_audit_task_completionexamples/app-showcase/src/data/hooks/index.tsafterUpdateprevious.done != true && record.done == trueshowcase_guard_task_reopenexamples/app-showcase/src/data/hooks/index.tsbeforeUpdateprevious.done == true && record.done != trueNo other shipped declarative hook condition names
previous. Plugin and service hooks readctx.previousin JS, not in a CEL condition. The 16 flow start conditions that readprevious(examples/app-showcase/src/automation/flows/**,examples/app-todo/src/flows/task.flow.ts) are the flow engine's, which already bindsnullon insert.Verification
CEL seam
Measured against the built
@objectstack/formula.previousprevious == nulltype: Unknown variable: previousprevious == nullnulltrueprevious != null && record.a > 5nullfalseprevious.xnullruntime: No such key: xprevious == null || record.a != previous.anulltruehas(previous.x)nullfalsebuildScope(packages/formula/src/stdlib.ts) bindspreviousonly when it is notundefined.Pins
packages/objectql/src/validation/rule-previous-insert-binding.test.ts(new) pins these cases:unevaluablereason. It admits a valid insert, and still reads the prior row on update.conditionalwithwhen: previous == nullruns its branch on insert.requiredWhen: previous == nullisrequiredon insert.previous.KEYread refuses asunevaluable, namingprevious.KEY.previousunbound, soprevious != null && …is refused, not admitted.packages/objectql/src/hook-condition-previous-scope.test.ts. The insert case flipped from "leavespreviousUNBOUND on insert" to two cases:previous == null && record.done == truefires onbeforeInsertandafterInsert, and the engine'sctx.previousstaysundefined.previous.done != true && record.done == trueonbeforeInsertstill aborts with aHookConditionError,reason: 'unevaluable', and a message naming'previous.done'.packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.tsasserts that every refusal carriesvalidity_window_order's own declared message, read off the object, and nounevaluablereason. It drives the realObjectQLinsert path over a real SQL driver with the realSecurityPlugin.Red, then green
Validation pins.
099cc9d69(uncommitted pins): objectql pin 5 failed / 7; window-order 4 insert refusals failed.70374639e: 7 / 7 and 11 / 11. The objectqllocalsuite: 435 files, 8220 tests passed.scripts/ablation-replace.mjs):if (mode === 'insert') return null;toreturn undefined;gave 5 / 7 and 4 / 11 failed. The restore was proven: blob equal to HEAD,git diff HEADempty.Hook pin.
b0d12e2e9(flipped test, unchanged source): 2 failed / 25. The message received was… (type: Unknown variable: previous) ….ee0ed2834:if (isInsertEvent(ctx.event)) return null;toreturn undefined;gave 2 failed / 25. The restore was proven: blobfb6056cc7567equal to HEAD,git diff HEADempty.No
dist/is involved in either suite. objectql's tests import its source, and plugin-security aliases@objectstack/objectqltosrc/index.ts.Final head
a2863de08, throughscripts/pm/os-verify-lock.sh:src/validation/,src/hook-*,src/engine-after-hook-abort-unit.test.tsandsrc/bulk-write-per-row-hooks.test.ts: 41 files, 1300 passed.plugin-securitywindow-order: 11 / 11.pnpm --filter @objectstack/objectql typecheck:tsc --noEmitpluscheck:test-typecheckOK, with no new ledger signature.REST door
On a stock showcase boot of
b0d12e2e9,POST /api/v1/data/sys_user_permission_setwithvalid_from: 2027-01-02T00:00:00Zandvalid_until: 2027-01-01T00:00:00Z:VALIDATION_FAILED.fields[0]={ field: valid_until, code: rule_violation, message: "Valid Until must be later than Valid From. …" }, with noconstraint.Gates at
a2863de08Derived set.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 100 commands, and all 100 were run.check:skill-examples(needs theclient-reactdeclarations);check:dual-build-cjs-loads(needs a workspace build);check:i18n(needs the built CLI closure).turbo run build: all three exited 0.dispatch-gates --ranreported "100 derived, 100 run, 0 NOT-MEASURED, 0 UNRUN".pnpm check:doc-authoringexited 0.Changeset gates.
node scripts/check-changeset-no-major.mjs --base origin/mainexited 0: "This diff introduces nomajorbump". Its level axis reads the PR body, and a local run has none.--eventand this body: exit 0, "LEVEL AXIS: this PR declares clause-②yes (widening), and no package whose published source … it moves is gradedpatch".node scripts/check-adr-0087-registration.mjs --base origin/mainexited 0: "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)". It readsyes (widening)as not breaking.Acceptance notes
Published skill text (
skills/**). It still describespreviousas unbound on insert:skills/objectstack-data/references/data-hooks.md: the "previousis UNBOUND where there is no prior state" bullet, and the "apreviousreference on an insert" clause.skills/objectstack-formula/SKILL.md: the scope-table row "Insert events …, validation rule on insert | unbound", plus the note under the migration table.That surface is governed (Tier H) and outside this card's surface, so it is not edited here. The seat filed the correction as skills: objectstack-data and objectstack-formula still say
previousis unbound on insert; once PR #23971 lands it isnullin validation and hook conditions #23983 (domain:skills), to land after this PR. Its advice ("write insert-event conditions overrecordalone"; aprevious.xread on insert fails the write) is still safe. Its "unbound" wording is now false:previous == nullevaluates on insert.Dogfood comment.
packages/qa/dogfood/test/hook-after-condition-rollback.dogfood.test.tscomments thatpreviousis unbound on an insert. Its condition,previous.name != record.name, still faults on insert, because the column read offnullfaults. So the test's verdict is unchanged; only the comment's reason is stale. The objectql twin's comment (engine-after-hook-abort-unit.test.ts) is corrected here.EvalContext.previoustype. In@objectstack/formulait is typed for a row only, whilebuildScopealready bindsnull. Both surfaces therefore routenullthroughextra.Generated by Claude Code