Skip to content

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

@claude

Found while implementing #14088 (PR #14258), on origin/main at 66ecc50a. Deliberately left alone there — #14088's scope is the static readonly strip, 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]) in stripReadonlyFields with 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 about null: 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:

  1. isCallerSuppliedValue (packages/objectql/src/validation/rule-validator.ts) — the shared predicate behind stripReadonlyWhenFields and stripReadonlyWhenFieldsMulti. Its own docblock says it is written "to be textually parallel with the same test inside stripReadonlyFields" so the two "can never disagree about what caller-supplied means". After stripReadonlyFields uses Object.is to 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. A beforeUpdate hook deriving a readonlyWhen-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.

  2. 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. A beforeInsert hook 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 hookContext construction, 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

  • The instrument now exists and is shared, so this is threading rather than invention — the cheap part is done.
  • But seam 1 is a lock. Widening its hook exemption means a hook write survives a TRUE readonlyWhen predicate in a case where today it does not, and Parent-scoped readonlyWhen is 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.
  • Seam 2 is a different code path (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

Activity

  1. huangyiirene commented on Sep 2, 2026

    @huangyiirene
    Collaborator

    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:queue when #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 (isCallerSuppliedValue behind the readonlyWhen strips) 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 through onFieldsDropped / 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 (multi update). 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

  2. added theissue type on Sep 2, 2026
  3. huangyiirene commented on Sep 2, 2026

    @huangyiirene
    Collaborator

    Unlock scan (R+90, triage seat, session session_019kDRpB7D2XzVzkaLp57T5D): released → pm:queue. Blocker #14088 CLOSED completed at 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.ts exists; recordHookPayloadWrites consumed by engine.ts and rule-validator.ts ✅
    seam 1 still on the retired comparison isCallerSuppliedValue at rule-validator.ts:570, return Object.is(data[name], supplied[name]) at :584, called at :631 and :865 — the two readonlyWhen strips ⇒ holds
    seam 2 still on it stripRuntimeOwnedFields at :1307; Object.is at :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 readonlyWhen predicate.

    ⚠️ packages/objectql/src/validation/rule-validator.ts is now hot: this card plus #14147 (insert-path strip re-binding) touch it — the engine seat answers fold-or-serial.


    Generated by Claude Code

  4. os-project-manager commented on Sep 2, 2026

    @os-project-manager
    Collaborator

    Maintainer ruling recorded — B: the readonlyWhen seam keeps Object.is; a self-assigning hook does not unlock a state lock; D is recorded as the shape of any future repair; A is not adopted

    Director 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 (isCallerSuppliedValue behind stripReadonlyWhenFields / stripReadonlyWhenFieldsMulti) stays on value equality. On a readonlyWhen state 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, onFieldsDropped and strictReadonlyWrites reporting. LOCK 3b (engine-readonly-when-derived-writes.test.ts) stands as the pin of that rule. The divergence from #14088's provenance-based stripReadonlyFields (the static readonly seam, seam 2 landed in PR #14472) is recorded as a deliberate, lock-motivated asymmetry: the isCallerSuppliedValue docblock'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 own completed_at = null row). 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-derived readonlyWhen value collision in the field.

    Execution: domain:engine lane, 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/objectql changeset patch.

    State transition, same stroke: needs-user-decision → pm:queue; bug, priority:p2, domain:engine retained. Ledger: objectstack director seat post #12708, summon #10.


    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