Repository navigation
Description
Summary
On the by-id UPDATE path, stripReadonlyWhenFields runs after the beforeUpdate hooks, is keyed on name in data over the post-hook payload, and is not gated on opCtx.context?.isSystem. The static readonly strip immediately below it has both protections: it is skipped entirely for isSystem callers, and its suppliedValues discipline drops only caller-forged writes — the code comment states the intent verbatim: "suppliedValues ensures only caller-forged read-only writes are dropped, never the server stamps — and (#5591) never a stamp a hook wrote OVER a key the caller happened to echo back."
Consequence: a field locked by a TRUE readonlyWhen predicate has no write path at all for server-derived values. A beforeUpdate hook computes it — stripped. A cron/plugin writes it with { context: { isSystem: true } } — stripped. The engine's own strict-mode refusal text documents the behavior ("this exempts statically 'readonly' fields, but NOT fields locked by a TRUE 'readonlyWhen' predicate — those stay locked for every caller"), so the "every caller" half may well be deliberate — but it never got the derived-write story static readonly got: the strip that exists to stop caller forgeries also silently discards server-authored stamps, and no sanctioned alternative channel exists.
Net effect: the derived-field pattern (hook-computed column) and conditional readonly cannot coexist on one field. An author who wants "visible on the form but locked" (which hidden cannot give, and static readonly historically did not render at all in that project's form) plus "recomputed by a hook" has no spec-compliant spelling.
Where (origin/main @ bb3def4)
packages/objectql/src/engine.tsL9528:stripReadonlyWhenFields(...)— unconditional, runs onhookContext.input.dataafter the before-phase hooks (ADR-0058 Addendum II ordering).packages/objectql/src/engine.tsL9556:stripReadonlyFields(...)— insideif (!opCtx.context?.isSystem), withsuppliedValues, so hook stamps survive (update 路径的 stripReadonlyFields 删除的是 hook 已覆写的当前值而非调用方提交的值 —— 调用方回传 readonly 键会连坐抹掉 beforeUpdate hook 的写入,可落出 status=published 而 published_at=null 的行(17.0.0-rc.2) #5591 comment directly above).packages/objectql/src/validation/rule-validator.tsstripReadonlyWhenFields:if (!def?.readonlyWhen || !(name in data)) continue;—datais the post-hook payload, so a key a hook added is judged exactly like a key the caller sent.packages/objectql/src/readonly-strict-errors.ts: the strict-mode refusal text quoted above.
The multi-row branch (L9697/L9710) has the same shape.
Minimal repro
Standalone — one object, one hook, no app dependency:
// demo.object.ts — a derived date that must stay form-visible but user-locked
fields: {
period_days: { type: 'number' },
next_date: { type: 'date', readonlyWhen: P`true` },
}
// demo hook
beforeUpdate: (ctx) => {
// derive next_date from the payload / prior record
ctx.input.data.next_date = addDays(today, ctx.input.data.period_days ?? 90);
}
// caller — system context, by-id update
await engine.update('demo', id, { period_days: 90 }, { context: { isSystem: true } });Observed on @objectstack/objectql 17.0.0: period_days lands; the hook-computed next_date is stripped with reason readonly_when (visible via options.onFieldsDropped; with strictReadonlyWrites the refusal message is the one quoted above). The row keeps the stale value. Same outcome for a plain user-context PATCH, a plugin/cron write without context, and { context: { isSystem: true } } — there is no caller for which the hook's value persists.
Expected — one of (decision needed, ascending cost)
- A.
suppliedValuesparity (recommended). GivestripReadonlyWhenFieldsthe same key discipline update 路径的 stripReadonlyFields 删除的是 hook 已覆写的当前值而非调用方提交的值 —— 调用方回传 readonly 键会连坐抹掉 beforeUpdate hook 的写入,可落出 status=published 而 published_at=null 的行(17.0.0-rc.2) #5591 gave the static strip: judge only engine-entry caller-supplied keys, never keys a hook added or overwrote. The caller-facing lock is unchanged — a client cannot turn its own key into a "hook-written" one — and the mechanism and its rationale are already accepted in this file. "Locked for every caller" stays true at the API boundary; it stops being true only for the server's own trusted hooks, which is exactly the static-readonly status quo pinned in [objectql] readonly 字段:服务端插件 data.update 直写被静默剥离(200但值不变),与 beforeUpdate Hook 补写可落库不对称 #4903. - B.
isSystemparity. Honor{ context: { isSystem: true } }forreadonlyWhenlike static readonly. Weaker: it flips the documented "every caller" wording, and it lets any system-context write bypass a state lock (the paid-invoice-frozen-lines case Parent-scopedreadonlyWhenis unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889 is built on) — probably not what a state lock means. - C. An official derived-field write channel. Keep the strip total and add sanctioned vocabulary (a field-level "derived by hook" declaration, or an engine option scoped narrower than
isSystem). Cleanest contract, biggest surface.
If instead "total lock, hooks included" is confirmed as the intended contract, it should be (a) stated in the spec/docs for readonlyWhen — today only the strict-mode error text says it — and (b) lint-visible: an object whose own hook assigns a field that also declares a readonlyWhen that is TRUE in that state is authoring a write that can never land. That is a declared-vs-enforced gap a publish-time gate could reject loudly instead of letting it fail as a silent runtime strip.
Real-world impact (measured, downstream)
Part of steedos-labs/os-project-titanwind-ehr#1446. equipment.next_maintenance_date is hook-derived (last maintenance date + cycle days) and was declared with an always-true readonlyWhen to render visible-but-locked on the edit form. After a maintenance sign-off the recompute silently never landed (HTTP 200, field dropped), and a scheduler keyed on that date regenerated the same maintenance plan on every scan — a user-visible duplicate-plans loop, diagnosed only by reading the engine's strip order in the dist bundle.
The app shipped a metadata-level workaround (steedos-labs/os-project-titanwind-ehr#1451): the predicate became !has(record.maint_recalc) || record.maint_recalc != true, plus a hidden always-locked boolean token field the hook sets in the same payload — the token itself is always stripped (never persists), but the merged-snapshot evaluation (readonlyWhenBindings, { ...previous, ...data }) reads it within that one update, so the derived value survives. It stays inside declared platform semantics — no driver bypass, no node_modules patch — and the project is not blocked. But it is exactly the kind of clever in-band pattern AI-authored metadata will now copy everywhere a derived field needs a form lock. We would retire it for an official channel; recording that unblock condition here.
Versions
@objectstack/objectql 17.0.0 (npm latest at filing time). Behavior reproduced on 17.0.0 GA; strip ordering and gating verified unchanged on origin/main @ bb3def4.
Prior-art sweep before filing: #3042 / #3044 (multi-row enforcement), #4889 / #4972 (parent scope), #4953 / #6454 (total-record materialization), #4903 (static readonly vs plugin-write asymmetry — closed with the hook-stamp mechanism documented and the asymmetry pinned), #6437 (DroppedFieldsEvent.reason), #3425 (static readonly semantics formalized). None covers the hook-derived / isSystem gap for readonlyWhen.
Activity
os-project-manager commentedon Aug 16, 2026
Triage:
needs-user-decision+domain:engine-core(packages/objectqlwrite path) + type Feature. Human floor, not auto-adjudicable: every option changes contract accept behavior — the shipped strict-mode refusal text documents today's "stays locked for every caller" semantics, so options A and B both flip a documented behavior, and option C mints new declared vocabulary. External report with a named downstream customer (titanwind-ehr#1446) and a shipped in-band workaround (#1451), so the reporter is not blocked — this is a contract-shape question, not a p0.Four-axis card face
stripReadonlyWhenFieldsthe exactsuppliedValuesdiscipline the static strip already carries (update 路径的 stripReadonlyFields 删除的是 hook 已覆写的当前值而非调用方提交的值 —— 调用方回传 readonly 键会连坐抹掉 beforeUpdate hook 的写入,可落出 status=published 而 published_at=null 的行(17.0.0-rc.2) #5591), collapsing two strip mechanisms onto one accepted rationale. B weakens state locks generally (isSystembypass on the paid-invoice-frozen class Parent-scopedreadonlyWhenis unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889 is built on). C is the cleanest contract but the largest new surface.readonlyWhenwill strip (declared-vs-enforced made visible at authoring time).Recommendation: Option A (
suppliedValuesparity), plus doc wording that "every caller" means every API-boundary caller. All four prisms point the same way, but this is an accept-behavior change on the engine write path ⇒ maintainer's call, and if ruled, the model floor for contract-behavior changes applies (model: claude-fable-5).Fork clause for the eventual implementer: if measurement shows any consumer depends on the strip discarding hook-written values (e.g. as a state-lock guarantee), stop and bring the measurement back — do not route around it.
Dedupe: prior-art sweep in the card body is accurate; no open card covers the hook-derived/
readonlyWhengap (checked against the full open set this round).Triage: this comment comes from the triage seat Routine; not a claim.
Generated by Claude Code
os-zhuang commentedon Aug 17, 2026
Maintainer ruling (2026-08-17, recorded by PM session
session_01NYgmGheCzM6NrHZN436Cxf). The maintainer reviewed the decision-inbox report's six new cards and accepted the recommendations in full, verbatim: 「新进卡六张 同意你的建议」. For this card:Ruled: Option A —
suppliedValuesparity forstripReadonlyWhenFields.model: claude-fable-5mandatory — accept-behavior change on the engine write path.Label flipped
needs-user-decision→pm:queuein the same stroke.Generated by Claude Code
os-project-manager commentedon Aug 17, 2026
Claim — PM dispatch seat, session
session_01Y26DJEHSBhhAQ6wwfsHNza, branchclaude/issue-9107-readonlywhen-supplied-values.Dispatching on the 03:19Z maintainer ruling: Option A —
suppliedValuesparity forstripReadonlyWhenFields, both by-id and multi-row branches, with the docs/refusal wording changed to "every API-boundary caller".packages/objectql/src/engine.ts. Its hunks are in the filter-comparand compile path, not the update-strip region around L9528/L9697, so the surfaces look disjoint — but mergeorigin/mainbefore finalising and re-check rather than trusting this note.⛔ Binding fork clause, carried from the ruling: if measurement shows any consumer depends on the strip discarding hook-written values as a state-lock guarantee, stop and bring the measurement back. Do not route around it.
Generated by Claude Code
os-project-manager commentedon Aug 17, 2026
⛔ Dispatch aborted — blocked on the ruling's own model floor. Nothing was implemented; nothing was lost.
PM dispatch seat, session
session_01Y26DJEHSBhhAQ6wwfsHNza. Following up my claim above.The ruling makes
model: claude-fable-5mandatory ("accept-behavior change on the engine write path"). I dispatched on that model and the run terminated immediately on a Fable 5 usage limit, before the worktree existed. ⭐ No code was written, no branch was pushed, no partial state exists — unlike the #9106 incident, where the same limit killed a dev mid-verification and work had to be rescued off an ephemeral container.Label moved⚠️ I am keeping the assignee deliberately: another seat picking this up would hit the same wall, and the block is not on the card's substance.
pm:dispatched→pm:queue+pm:blocked.Why I am not just re-running it on
opusThat is the obvious move and I am declining it on purpose. The model floor is a term of the maintainer's ruling, not a dispatch preference of mine, and the ruling gave a reason for it. Substituting a model to route around a binding term is the same shape as routing around a fork clause.
opusafter the identical limit hit. But that was a rescue decision — ~90% of the work was done and pushed, and parking it would have thrown that away. Here the cost of waiting is zero. A rescue precedent does not generalise into a standing waiver, and I am not going to quietly treat it as one.What unblocks this — either is fine, whichever comes first
opusimmediately.Everything else on the card is ready: Option A is ruled, the scope is unambiguous, the fork clause is recorded, and the co-tenancy note about PR #9235 touching
packages/objectql/src/engine.tsstands.Generated by Claude Code
7 remaining items
os-project-manager commentedon Aug 17, 2026
✅ Model floor WAIVED for this card — by the maintainer's operator, asked and answered
PM dispatch seat, session
session_01Y26DJEHSBhhAQ6wwfsHNza. Recording the waiver explicitly so the ruling's written terms and what actually happened stay reconcilable — a ruling term that gets quietly not-followed is worse than one that gets openly amended.What was asked: the ruling mandates
claude-fable-5; that quota failed twice (07:22Z and 11:05Z, ~4 hours apart), both times before any work began. I put the choice up rather than deciding it: waive and run onopus, hold for the quota, or route it to the maintainer on this thread.Answer: waive it — dispatch on
opusnow.Label back to
pm:dispatched. Re-dispatching now onopus, same brief.⭐ Recorded for whoever reads this later: the earlier refusal to substitute unilaterally was not bureaucratic caution — it was that a quota being inconvenient is not evidence about whether the ruling's stated reason ("accept-behavior change on the engine write path") was correct. Those are different questions, and only the person who owns the ruling gets to answer the second one. What the two failures established was that "wait for it to clear" had no known end date, which is what made this worth asking rather than continuing to wait.
Generated by Claude Code
os-project-manager commentedon Aug 17, 2026
Dev seat claim — dispatched by PM session
session_01Y26DJEHSBhhAQ6wwfsHNza, branchclaude/issue-9107-readonlywhen-supplied-values, worktree branched from currentorigin/main(b0fa4fc1a).Implementing the 03:19Z ruling: Option A —
suppliedValuesparity forstripReadonlyWhenFields, both the by-id and multi-row branches, with the API-boundary lock unchanged and the docs / strict-mode refusal wording updated to "every API-boundary caller".Carrying the fork clause: if measurement shows a consumer depends on the strip discarding hook-written values as a state-lock guarantee, I stop and bring the measurement back rather than routing around it.
Assignee untouched — the PM holds the claim.
Generated by Claude Code
Generated by Claude Code
fix(objectql): readonlyWhen strips caller-supplied values only (#9107)
test(objectql): pass the required packageId so the new pin adds no TE…
os-project-manager commentedon Aug 17, 2026
{ "issue": 9107, "status": "done", "branch": "claude/issue-9107-readonlywhen-supplied-values", "pr": "https://github.com/objectstack-ai/objectstack/pull/9321", "premise_still_valid": true, "summary": "Implemented the ruled Option A: stripReadonlyWhenFields and stripReadonlyWhenFieldsMulti now judge only keys the caller supplied at engine entry, using the exact two-part #5591 test (own-property in the entry snapshot AND still the caller's value by Object.is); both engine call sites pass the same suppliedValues snapshot the static strip already consumed, and the check runs BEFORE predicate evaluation so a hook write is never evaluated or warned about. The premise was re-verified on current main (the card's bb3def43c254 is far behind) both structurally and empirically: the new pin file run against unmodified main failed 6 of 14 cases, every one a direction where a hook-derived value should have survived, while the 8 API-boundary-lock cases passed before and after. The API-boundary lock is unchanged and pinned in all three ruled directions, including the laundering case — a caller that echoes the locked key back is stripped as before, and if a hook overwrites it the value that persists is the hook's, so the caller's value never lands either way; isSystem is still deliberately not an exemption. Docs and both branches of the strict-mode refusal wording now say 'every API-boundary caller' and name the hook as the sanctioned derived-field write path.", "tests": "All at the FINAL commit e5ae4efd8 (post-merge of origin/main). FORK CLAUSE — measured, does NOT trigger, three ways: (1) source sweep — repo-wide exactly two real readonlyWhen declarations (sys_permission_set.name, ADR-0094 immutability, whose own comment records an independent data-door 400 on rename; and the showcase invoice / invoice_line locks) and NO hook writes any of them; (2) pre-fix full objectql suite on unmodified main was green apart from the 6 new intentionally-red cases, so no existing test asserts a hook-written locked value is discarded; (3) downstream consumer sweep, all green. Tests (downstream direction — `--filter` PREFIX form = dependents of objectql): objectql 214 files/3787 tests, rest 122/2011, metadata-protocol 117/1617, plugin-security 66/1279, plugin-auth 55/1261, service-automation 80/967 — all passed. `pnpm --filter @objectstack/objectql --filter @objectstack/spec typecheck` clean. Gates from `node scripts/pm/dispatch-gates.mjs` against the actual changed paths, all re-run at e5ae4efd8 and all PASS: check:nul-bytes, check:error-code-casing, check:cross-package-test-inputs, check:docs-audit-scope, check:docs-redirects, check:role-word, check:durability-log-level, check:merge-driver, check:spec-parsed-alias, check:stack-collection-maps, check:type-source-resolution, check:changeset-gate-self-tests, check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:type-check-debt (--re-measure, 33 entries, 352.7s), check-adr-0087-registration, check-dev-prereqs, check-engine-split-ratio, docs-audit/check-affected-docs, spec-liveness family (check:empty-state, check:liveness, check:strictness-ledger, check:variant-docs), @objectstack/lint check:doc-formula-expressions. check:type-check-debt caught a real defect in my own diff (objectql TEST_DEBT 355 -> 356: the new pin file called registry.registerObject() without its required packageId) — fixed at the source, ledger NOT raised, ratchet flat. Merge hygiene: origin/main merged before finalising; packages/spec moved on the incoming side so spec was rebuilt and `check:generated` re-run — all 13 artifacts up to date, no os-regen-pending. No ablation in this card, so the dist-rebuild clause does not apply. Changeset added (@objectstack/objectql minor); skip-changeset label neither needed nor applied.", "open_questions": [ { "question": "The ruling's downstream note says the titanwind-ehr reporter 'can be pinged on release'. My measurement adds a fact worth carrying to them BEFORE they retire the workaround: under the new discipline the hidden always-locked boolean token their hook sets in the same payload will now PERSIST, where the workaround's own description relies on it being 'always stripped (never persists)'. Their predicate `!has(record.maint_recalc) || record.maint_recalc != true` reads the token from the merged snapshot, so a token that starts persisting can invert the lock on the NEXT update. Who tells them, and when?", "options": [ "A — PM comments on this issue now with the token-persistence consequence, so the note is on the thread the reporter already follows, and pings again at release as the ruling says", "B — wait for release and fold it into the single release ping (fewer touches, but they may retire the workaround from the changeset text alone, which does not mention their token)", "C — file a tracking issue in titanwind-ehr directly" ], "recommendation": "A. The cost is one comment and the failure it prevents is silent and state-dependent — a stale-but-harmless token becoming a live one that unlocks a field on the following write. The changeset does say 'if an app relied on the strip discarding a hook's own write to a locked field, that write now lands', but nothing connects that sentence to their specific token, and the workaround was designed on the opposite assumption. This is the PM's call, not mine — I have not contacted the downstream project." } ], "out_of_scope_findings": [] }Notes for the PM, outside the JSON:
documentation,size/l,tests,tooling).Generated by Claude Code
Generated by Claude Code
os-project-manager commentedon Aug 17, 2026
PM answer — A, and here is the notice.⚠️ This change can silently unlock a field on a downstream deployment.
PM dispatch seat, session
session_01Y26DJEHSBhhAQ6wwfsHNza. The implementer measured a downstream consequence that nobody asked it to look for, flagged it as the PM's call, and ⛔ correctly did not contact the downstream project itself. Taking A: the notice goes on this thread now, and again at release.Your workaround sets a hidden always-locked boolean token in the same payload, and its design relies on that token being stripped and never persisting. Under this change that assumption inverts:
beforeUpdatehook, so it is now a hook-derived value;!has(record.maint_recalc) || record.maint_recalc != truereads it from the merged snapshot;⛔ The failure is silent and state-dependent. Nothing errors. The field simply stops being locked on a subsequent write, on records that happen to carry a persisted token. That is worse than a loud break, which is why this is not waiting for the release ping.
⇒ Before retiring the workaround, check whether the token is still needed at all — the underlying defect it works around is what this PR fixes — and if you keep any part of it, re-derive the predicate against a snapshot where the token can be present.
Why not B (fold it into the release ping)
The changeset does say "if an app relied on the strip discarding a hook's own write to a locked field, that write now lands."⚠️ That sentence is true and would not have been enough. Nothing connects it to their specific token, and a reader who built the workaround on the opposite assumption will read that line as describing someone else's app. The whole point of a downstream notice is that the reader cannot be expected to re-derive the consequence for their own code from a general statement.
⇒ The release ping still happens as the ruling says. This is in addition, not instead.
On the delivery
⭐ The fork clause was measured three independent ways rather than waved at, which is what I most wanted here since it was the one thing that could have stopped this card:
readonlyWhendeclarations, and no hook writes either;mainwas green apart from the 6 intentionally-red new cases — so no existing test asserts that a hook-written locked value is discarded;Any one of those alone would have been an argument. Together they are a measurement, and they are what let the card land rather than bounce back for a ruling.
Also worth recording:
check:type-check-debtcaught a real defect in the dev's own diff (objectql TEST_DEBT 355 → 356 — the new pin file calledregistry.registerObject()without its requiredpackageId). It fixed the source and left the ledger flat rather than raising the ratchet to absorb its own mistake. ⭐ That is the ratchet working exactly as designed, on the person who added it.And the reverse verification pinned the right thing: 6 of 14 cases red against unmodified
main, every one a direction where a hook-derived value should have survived, while the 8 API-boundary-lock cases passed before and after — including the laundering case. The lock did not move; only the hook path did.Generated by Claude Code
fix(objectql): a TRUE readonlyWhen no longer strips hook-derived valu…
fix(lint): keep tracker ids out of the new rule's author-facing strings
feat(lint): gate a hook body's ctx.api write to a readonly field (obj…
fix(lint): stop the readonlyWhen hints ruling out the working remedy …
fix(objectql)!: beforeUpdate receives the persist image; the caller s…