Skip to content

fix(objectql): the read-only strip's warning stops claiming a commit that strict mode refused, and offers preserveAudit only where it works (#8214) - #8250

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8214-readonly-strip-warning-truthful
Aug 13, 2026
Merged

fix(objectql): the read-only strip's warning stops claiming a commit that strict mode refused, and offers preserveAudit only where it works (#8214)#8250
os-zhuang merged 1 commit into
mainfrom
claude/issue-8214-readonly-strip-warning-truthful

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #8214

Two claims readonlyStripWarning — and its insert-side twin runtimeOwnedStripWarning — were making were not true of the call in front of them. The level is unchanged (warn, the level its own docblock argues for so real forgery attempts stay visible), every surviving line still names the field, the consequence and a remedy, and the write's own address still logs nothing (#8141), in strict mode as well.

Defect 1 — "COMMITTED WITHOUT IT" under strictReadonlyWrites

The strip logs from inside stripReadonlyFields / stripRuntimeOwnedFields; assertNoStrictDrops() (update) and the ReadonlyFieldRejectedError throw (insert) come afterwards, before any driver call. A strict caller was told in prose the write had been committed without the field while nothing had been written at all.

Measured on origin/main @ 29488cc, real ObjectQL plus a recording driver:

case refused code driver writes warn lines claimed a commit
update, strict, forged locked_note ERR_READONLY_FIELD_REJECTED 0 1 yes
insert, strict, seeded account_number ERR_READONLY_FIELD_REJECTED 0 1 yes

The insert-side half was filed UNVERIFIED. It reproduces.

The card flagged runtimeOwnedStripWarning as probably carrying the same claim while insert also discharges strictReadonlyWrites (#5126 / #5503), and marked it unverified. Measured above: same code, same zero driver dispatches, same false sentence, on the same sequencing (engine.ts strips at the stripRuntimeOwnedFields call, collects autonumberDropped, then throws before validation and before any driver call). It is the same defect, not a lookalike, so it is fixed in the same card and pinned in the same file.

The shape chosen, and why the other two are worse

Chosen: the strip learns the flag. stripReadonlyFields / stripRuntimeOwnedFields take strictReadonlyWrites, and the message composers take a strict option that swaps the consequence clause and the observe-instead remedy. The seam that composes the sentence is the seam that knows what the sentence describes.

  • Rejected: move the log after the decision. Under strict the throw happens first, so the line would never print — precisely the outcome the card forbids. The forgery signal is still wanted; only the claim of a commit is not. Recovering it would mean re-emitting a differently worded line from the throw site, i.e. option three below plus a restructure.
  • Rejected: give the strict path its own separate line. The false line has already printed by then, so the log would carry both "COMMITTED WITHOUT IT" and a refusal notice. A reader grepping for the first still gets the lie, and the two lines would have to be kept consistent from two places forever.
  • Rejected: return messages from the strip and let the engine log them. This changes the return contract of both strips, touches every caller and test, and splits message composition across two files — the wording would then live where the wording is hardest to pin.

The chosen shape keeps all wording in one exported composer, which is what lets the pins assert toBe(readonlyStripWarning(...)) and makes drift impossible.

Why engine.ts is in the diff (the scope fence asked me to say so explicitly)

Three lines, and they are genuinely required: the strip has no other way to learn the flag, because the caller builds the options object. The by-id update call site, the multi update call site, and the insert call site each pass strictReadonlyWrites down. Nothing else in engine.ts moved.

That each of the three is load-bearing is not asserted, it is measured — see the reverse verification below, where reverting engine.ts alone fails exactly three cases, one per call site. The multi branch is pinned separately for this reason: a fix threaded only through the by-id site would have left updateMany still claiming a commit.

Defect 2 — the remedy named isSystem but never preserveAudit

stripReadonlyFields honours context.preserveAudit (#3493) three lines above the log, a whitelist narrower than isSystem by construction, while the message offered only the blanket exemption. An import that forgot the flag was steered to the strictly worse posture — the identical failure #8141 fixed one remedy over.

The card's question: targeted or blanket? Answer: targeted — and derived, not described.

The remedy is now emitted per field, gated on isPreservableUnderAudit(name, def) — the same predicate the strip itself consults one line earlier. Three reasons, in order of weight:

  1. A blanket sentence would be false for real fields. preserveAudit does not rescue everything the strip takes: a non-audit system column is stripped under preserveAudit too (no tenancy-forging backdoor). Measured on organization_id: the flag is set, the field is stripped anyway, the line prints. Advertising preserveAudit there repeats The read-only strip still logs a WARN calling the addressed row's own id a forged caller write, on every single-record update of a platform object #8141's defect one exemption over — a caller sent to a posture that does not help. The gated sentence withholds it, and there is a pin for exactly that.
  2. Deriving it cannot go stale when A preserveAudit by-id update hands the primary key to the driver: isPreservableUnderAudit reinstates a platform object's own id #8215 lands. The whitelist is AUDIT_TIMELINE_FIELDS plus any non-system field, and that second limb is under revision — A preserveAudit by-id update hands the primary key to the driver: isPreservableUnderAudit reinstates a platform object's own id #8215 (pm:blocked) records that it currently sweeps in a platform object's own primary key. A sentence describing the whitelist becomes false the day that narrowing lands. A sentence gated on the predicate narrows with it automatically, with no second declaration to forget. This is why the message never describes the whitelist at all: it names the scenario (a historical import restoring this record's own earlier values) and lets the predicate decide whether to say it. Nothing in this PR changes isPreservableUnderAudit itself; A preserveAudit by-id update hands the primary key to the driver: isPreservableUnderAudit reinstates a platform object's own id #8215 stays untouched.
  3. It resolves the sibling disagreement at its root. The card's complaint is that two messages disagreed about the remedy set for one pair of exemptions. Both now read the same predicate, so they cannot disagree by construction — which is a stronger fix than copying one message's wording into the other.

The derivation is also safe by position: a field kept by preserveAudit never reaches the log, so any field that does reach it while the flag is on is one the whitelist refused, and the remedy is correctly withheld. Pinned.

The two final texts, verbatim

readonlyStripWarning, default (non-strict) — byte-identical to before this PR apart from the derived sentence, verified by diffing emitted lines pre and post:

Field 'locked_note' on 'pref' is read-only: the caller-supplied value was DROPPED and the update is being COMMITTED WITHOUT IT — the call returns success while this column keeps its stored value (#2948). Server-side code that legitimately writes read-only columns (a plugin, a cron / background job persisting a system-computed value) must declare itself trusted by passing { context: { isSystem: true } } on the write; a beforeUpdate hook does NOT need this because hook-written keys are not caller-supplied. A historical import restoring this record's own earlier values does NOT need that blanket exemption: pass the narrower historical-import context { context: { preserveAudit: true } } (#3493), which reinstates THIS field while the rest of the strip stays in force. To detect drops programmatically instead of reading this log, pass options.onFieldsDropped (#3407). Forged read-only keys from untrusted client input are expected here and need no action.

readonlyStripWarning, strict:

Field 'locked_note' on 'pref' is read-only: the caller-supplied value was DROPPED and the update is being REFUSED ENTIRELY — this write passed options.strictReadonlyWrites, so NOTHING is written: not this column, and not the fields that would have survived the strip. The call throws ERR_READONLY_FIELD_REJECTED rather than returning success (#5126). Server-side code that legitimately writes read-only columns (a plugin, a cron / background job persisting a system-computed value) must declare itself trusted by passing { context: { isSystem: true } } on the write; a beforeUpdate hook does NOT need this because hook-written keys are not caller-supplied. A historical import restoring this record's own earlier values does NOT need that blanket exemption: pass the narrower historical-import context { context: { preserveAudit: true } } (#3493), which reinstates THIS field while the rest of the strip stays in force. To let the strip happen and merely observe it instead of refusing the write, drop options.strictReadonlyWrites and pass options.onFieldsDropped (#3407). Forged read-only keys from untrusted client input are expected here and need no action.

For a field the whitelist would not keep (organization_id), the preserveAudit sentence is absent from both and isSystem is still offered.

runtimeOwnedStripWarning, default:

Field 'account_number' on 'pref' is a runtime-owned 'autonumber' field: the caller-supplied value was DROPPED and the write is being COMMITTED WITHOUT IT — the runtime issues this value from its sequence, so the call returns success while the column holds the generated number, not the one sent (#5503). Server-side code that legitimately sets it (seed replay, a migration) must declare itself trusted by passing { context: { isSystem: true } }; a data import reinstating legacy record numbers uses the historical-import context ({ context: { preserveAudit: true } }, #3493), which reinstates THIS field while the rest of the strip stays in force. A beforeInsert/beforeUpdate hook does NOT need either — hook-written keys are not caller-supplied. To detect drops programmatically instead of reading this log, pass options.onFieldsDropped (#3407). Forged record numbers from untrusted client input are expected here and need no action.

runtimeOwnedStripWarning, strict:

Field 'account_number' on 'pref' is a runtime-owned 'autonumber' field: the caller-supplied value was DROPPED and the write is being REFUSED ENTIRELY — the runtime issues this value from its sequence, and this write passed options.strictReadonlyWrites, so NOTHING is written: not this column, and not the fields that would have survived the strip. The call throws ERR_READONLY_FIELD_REJECTED rather than returning success (#5126). Server-side code that legitimately sets it (seed replay, a migration) must declare itself trusted by passing { context: { isSystem: true } }; a data import reinstating legacy record numbers uses the historical-import context ({ context: { preserveAudit: true } }, #3493), which reinstates THIS field while the rest of the strip stays in force. A beforeInsert/beforeUpdate hook does NOT need either — hook-written keys are not caller-supplied. To let the strip happen and merely observe it instead of refusing the write, drop options.strictReadonlyWrites and pass options.onFieldsDropped (#3407). Forged record numbers from untrusted client input are expected here and need no action.

The #8216 pin — updated, not worked around

PR #8216 (#8141) asserted this line byte-identical on purpose, because its own bar was to prove it had narrowed the warning rather than deleted it. Those pins are updated in place: they now state which variant they expect ({ strict: true }, { preserveAuditApplies: true }) instead of assuming one. Nothing was silenced to make them pass, and the counter-cases #8216 built — a forged field riding along with the address must still warn, at warn, byte-identical to the composer — are all still there and still green, now in strict mode too.

Reverse verification

Directions were predicted in writing before running. Both were correct, and the first carried an instructive partial surprise.

C1 — revert rule-validator.ts only. Predicted red, plus a type error at the engine call sites. Observed: 3 type errors ('strictReadonlyWrites' does not exist in type ...), one per threaded call site, and 8 test failures.

The surprise worth recording: fewer test failures than the naive expectation, because a pin written as expect(warns).toEqual([readonlyStripWarning(...)]) computes its expectation from the same reverted composer, so both sides move together and the pin stays green. Those pins verify that the strip passes the right arguments, not that the text is right. The literal assertions (not.toContain('COMMITTED WITHOUT IT'), toContain('REFUSED ENTIRELY')) are what actually caught the revert — which is why both kinds are present rather than only the tidier one.

C2 — revert engine.ts only. Predicted red in a different place: the pure unit pins in rule-validator.test.ts stay green (they call the strip directly with the option), while the engine-level strict pins go red, and fewer failures than C1. Observed exactly that — 3 failures, all in engine-strict-readonly-warning-truthful.test.ts, rule-validator.test.ts fully green (191 passed). The three are the by-id branch, the multi branch and the insert path: one per threaded line, which is the measurement behind the engine.ts justification above.

Verification

  • pnpm --filter @objectstack/objectql typecheck — exit 0
  • pnpm --filter @objectstack/objectql test — 194 files, 3436 tests, all passing
  • Non-strict behaviour proved unchanged by diffing the emitted lines pre and post fix: identical for the default insert and for a field the whitelist does not cover.
  • Test-layer type errors (objectql hides its tests from tsc via its own tsconfig exclude, so typecheck does not see them): measured with a temporary config that includes them — 355 before, 355 after. This PR adds none, so the TEST_DEBT ratchet cannot rise. The one error inside a file this PR touches is on a line byte-identical to origin/main.
  • Gates derived with node scripts/pm/dispatch-gates.mjs over the changed paths, all exit 0: check:nul-bytes, check:error-code-casing, check:durability-log-level, check:engine-double-contract, check:cross-package-test-inputs, check:adr-anchors, check:stack-collection-maps, check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check-changeset-no-major.mjs, check-engine-split-ratio.mjs. No baseline was raised or lowered.
  • check:objectui-pin-fresh exits 1 — pre-existing and unrelated: it reads .objectui-sha (last moved by chore: bump the console pin to objectui 6d77acfe3125 (123 commits, not 405) #8137) against objectui's remote tip, and this diff does not touch that file. A changeset merely triggers the gate to run; it cannot change its verdict. Refreshing the pin is the documented maintainer-triggered procedure, not a rider on a code PR.

Scope

packages/objectql only. No packages/spec, no docs, no ADR, nothing under content/docs/releases/. record-validator.ts untouched (#7501's reservation). isPreservableUnderAudit untouched (#8215 stays blocked and unaffected). None of the four functions involved is exported from the package barrel, so no public surface widens and no downstream package can break.

patch changeset: observable log behaviour changes.


Generated by Claude Code

…trict mode refused (#8214)

Two claims `readonlyStripWarning` and its insert-side twin
`runtimeOwnedStripWarning` made were not true of the call in front of them.

1. Under `options.strictReadonlyWrites` the strip logged "COMMITTED WITHOUT IT"
   while the write was refused outright and nothing reached the driver. The
   strip logs from inside; `assertNoStrictDrops()` (update) and the
   `ReadonlyFieldRejectedError` throw (insert) come afterwards. Measured on
   origin/main, real ObjectQL + a recording driver: refusedCode
   ERR_READONLY_FIELD_REJECTED, driverWrites/driverCreates 0, warnLines 1,
   claimsCommitted true, on BOTH paths. The insert half was filed unverified;
   it reproduces.

   The strip now learns the flag and reports the refusal instead. The line is
   NOT dropped in strict mode and the level is unchanged (`warn`) — a forged
   write that got refused is the case that most deserves a visible line.

2. The remedy named `{ context: { isSystem: true } }`, the blanket exemption,
   and never `preserveAudit` (#3493) — the narrower whitelist this same strip
   honours three lines up. It is now offered per field, derived from
   `isPreservableUnderAudit` rather than described in prose, so it can never
   advertise an exemption that would not have worked (`organization_id` is
   stripped under `preserveAudit` too) and cannot drift when #8215 narrows
   that predicate.

Default, non-strict writes are byte-identical, verified by diffing the emitted
lines before and after. The write's own address still logs nothing (#8141), in
strict mode as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014C8pAprWdmtecFsEprZax4
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 12, 2026 11:33pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/system-context.mdx (via packages/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 13, 2026 00:08
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit c41828d Aug 13, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8214-readonly-strip-warning-truthful branch August 13, 2026 00:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

readonlyStripWarning promises a commit that strict mode refuses, and omits the preserveAudit remedy its own strip implements

2 participants