Repository navigation
The readonlyWhen strip and the insert-side runtime-owned strip still decide hook-vs-caller by Object.is, the comparison #14088 retired one function over #14259
Description
Activity
Triage (R+89, triage seat, session
session_019kDRpB7D2XzVzkaLp57T5D):pm:blocked·priority:p2·domain:engine· type Bug.Blocked-by: #14088
Why blocked: both seams consume the hook-write provenance recorder that #14088 (PR #14258, in flight under contract review) introduces; landing this first would invent a second recorder. The unlock scan returns it to
pm:queuewhen #14088 closes.The scope question is answered by inheritance, so the implementer does not re-open it: seam 2 (
stripRuntimeOwnedFields, insert side) is the mechanical port. Seam 1 (isCallerSuppliedValuebehind thereadonlyWhenstrips) inherits #14088's ruling with its reason — value equality cannot tell "the hook wrote what the caller also sent" from "the hook never touched the key", and those demand opposite verdicts; the #4889 lock is about caller writes surviving a TRUE predicate, and provenance leaves that untouched. ⛔ Binding constraint carried verbatim: a caller-supplied value must never become hook-owned; a caller-supplied value no hook wrote is still stripped, still warns with the same text, still reports throughonFieldsDropped/strictReadonlyWrites. If the implementer measures a case where seam 1 under provenance lets a caller value survive a TRUE predicate, that is a fork report to the decision inbox, not a unilateral choice. Seam 3 (arming the recorder before the middleware chain) is out — its own card if anyone measures a middleware stamp that needs it.Family: #14088 (mother) · #14147 (insert-path strip re-binding, queued today) · #14099 (
multiupdate). Hot file:packages/objectql/src/validation/rule-validator.ts.Size/model suggestion: M,
opus; the negative control (identical caller payload, no hook write ⇒ stripped) is the deliverable's proof.
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 2, 2026 Unlock scan (R+90, triage seat, session
session_019kDRpB7D2XzVzkaLp57T5D): released →pm:queue. Blocker #14088 CLOSEDcompletedat 02:25:14Z via merged PR #14258.Card face re-verified on the merged ref (
origin/main=72adb7f), ⛔ not a naive unlock:reading unblocker present packages/objectql/src/hook-write-provenance.tsexists;recordHookPayloadWritesconsumed byengine.tsandrule-validator.ts✅seam 1 still on the retired comparison isCallerSuppliedValueatrule-validator.ts:570,return Object.is(data[name], supplied[name])at:584, called at:631and:865— the tworeadonlyWhenstrips ⇒ holdsseam 2 still on it stripRuntimeOwnedFieldsat:1307;Object.isat:1216⇒ holds⇒ The instrument now exists and is shared; both sibling seams still decide hook-vs-caller by value equality. Everything in this card's triage stands unchanged — the ruling inherited from #14088 (provenance, not equality), the binding forgery boundary (⛔ a caller-supplied value must never become hook-owned), seam 3 out of scope, and the fork-report clause if seam 1 under provenance is measured to let a caller value survive a TRUE
readonlyWhenpredicate.⚠️ packages/objectql/src/validation/rule-validator.tsis now hot: this card plus #14147 (insert-path strip re-binding) touch it — the engine seat answers fold-or-serial.
Generated by Claude Code
os-project-manager commented
on Sep 2, 2026 CollaboratorMore actionsMaintainer ruling recorded — B: the
readonlyWhenseam keepsObject.is; a self-assigning hook does not unlock a state lock; D is recorded as the shape of any future repair; A is not adoptedDirector seat (objectstack #12708), summon #10, session
session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02.Provenance (who / verbatim / where): maintainer, live PM chat with the director seat, 2026-09-02, replying to decision batch #12 in which this card was item 4 with the recommendation B (D recorded as the future shape; A explicitly not adopted; C rejected on measurement), the engine seat's recommendation in comment 5506819337. Verbatim reply: 「同意」.
Ruled: B. Seam 1 (
isCallerSuppliedValuebehindstripReadonlyWhenFields/stripReadonlyWhenFieldsMulti) stays on value equality. On areadonlyWhenstate lock, a before-phase hook assignment that writes back the value already on the key (data.x = data.x, or a normalisation that is the identity for canonical input) does not count as a hook write: the caller's value is stripped, with the existing warning,onFieldsDroppedandstrictReadonlyWritesreporting.LOCK 3b(engine-readonly-when-derived-writes.test.ts) stands as the pin of that rule. The divergence from #14088's provenance-basedstripReadonlyFields(the staticreadonlyseam, seam 2 landed in PR #14472) is recorded as a deliberate, lock-motivated asymmetry: theisCallerSuppliedValuedocblock's "can never disagree" sentence is corrected to state it, and each face carries a measurement pin (the static face: a lone self-assigning hook leaves the caller value on the key, already pinned; the lock face: it does not). The residual #9107 input (a hook deriving a locked field while the caller echoed the identical value loses its write) is accepted; no instance exists in the tree.Not adopted: A (an innocuous
normalize(data.x)line would silently unlock a state lock; #4889's frozen-invoice class becomes bypassable). Rejected on measurement: C (value-differs recording re-breaks #14088's owncompleted_at = nullrow). Recorded as the future shape: D — hook ownership of a locked column as a declared act on the hook context that both strips and #14088 read. D is not built now (no measured pull, new public surface); the observation that would buy it is a real hook-derivedreadonlyWhenvalue collision in the field.Execution:
domain:enginelane, S: docblock correction plus the two face pins;Fixes #14259. Clause-② no (no accept-set movement; the behaviour ruled is today's behaviour, now written down).@objectstack/objectqlchangeset patch.State transition, same stroke:
needs-user-decision→pm:queue;bug,priority:p2,domain:engineretained. Ledger: objectstack director seat post #12708, summon #10.
Generated by Claude Code
- added a commit that references this issue
on Sep 6, 2026
Found while implementing #14088 (PR #14258), on
origin/mainat66ecc50a. Deliberately left alone there — #14088's scope is the staticreadonlystrip, and one of the two seams below is a state LOCK whose relaxation is a decision, not a mechanical follow-through.The defect
#14088 replaced
Object.is(payload[name], supplied[name])instripReadonlyFieldswith a record of the keys the before-phase hook chain actually assigned (recordHookPayloadWrites,packages/objectql/src/hook-write-provenance.ts). The argument for that was not aboutnull: value equality cannot separate the hook deliberately wrote the value the caller also sent from the hook never touched the key, and those demand opposite verdicts.Two sibling seams still use the comparison that argument retired, and both are in the same file:
isCallerSuppliedValue(packages/objectql/src/validation/rule-validator.ts) — the shared predicate behindstripReadonlyWhenFieldsandstripReadonlyWhenFieldsMulti. Its own docblock says it is written "to be textually parallel with the same test insidestripReadonlyFields" so the two "can never disagree about what caller-supplied means". AfterstripReadonlyFieldsusesObject.isto tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 they do disagree. AbeforeUpdatehook deriving areadonlyWhen-locked field loses its write to any caller that echoed the same value back — the [objectql] TRUE readonlyWhen strips beforeUpdate-derived values too — a conditionally-locked derived field has no server-side write path at all (isSystem included), unlike static readonly's hook-stamp protection #9107 defect, surviving on the one input [objectql] TRUE readonlyWhen strips beforeUpdate-derived values too — a conditionally-locked derived field has no server-side write path at all (isSystem included), unlike static readonly's hook-stamp protection #9107's fix cannot see.stripRuntimeOwnedFields(same file, the INSERT-side twin). insert 路径 stripRuntimeOwnedFields 同样删的是 hook 覆写后的当前值 —— beforeInsert hook 写入的 autonumber 在调用方也提交了同名键时被连坐删除(与其自身文档矛盾) #6339's own prose is the finding: it argued the key-SET judgement "made that sentence true only by accident" and moved to values, which is accidental in the identical way. AbeforeInserthook re-issuing or normalising a record number loses its write when the caller happened to submit the same value.A third, narrower seam is worth naming in the same breath: the #14088 recorder is armed at
hookContextconstruction, i.e. after the middleware chain, so a MIDDLEWARE stamp that writes the value the caller also sent is still judged by value equality. Arming earlier is a one-line move with the same forgery argument, but it widens what the record covers and deserves its own measurement.Why it was not folded into #14088
readonlyWhenpredicate in a case where today it does not, and Parent-scopedreadonlyWhenis unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889's frozen paid-invoice lines are built on that lock. That is a maintainer-shaped call, not a mechanical follow-through, and it should not ride in on a p1 corruption fix.engine.insert's per-row hook contexts) with its own fixtures — a new verification surface, which is exactly the boundary the in-place-fix exemption draws.The constraint any repair inherits
⛔ The forgery boundary from #14088 applies unchanged and is the part where a mistake is worse than the bug: a caller-supplied value must never become hook-owned. The record is safe only because it is armed after the caller's payload has arrived and sealed before any engine-owned pass touches it, and because it records that an assignment ran rather than anything about the payload's contents. Any producer of a hook-written key set owes the same proof.
⛔ And it is not a relaxation: a caller-supplied value that no hook wrote must still be stripped, still warn with the same text, and still report through
onFieldsDropped/strictReadonlyWrites.Reproduction shape
The same one #14088 uses, retargeted: a hook that writes a locked/runtime-owned field the value the caller also sent, and the negative control of the identical caller payload with no hook write, which must reach the opposite verdict.
Unassigned and untriaged.
Generated by Claude Code