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
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #8214
Two claims
readonlyStripWarning— and its insert-side twinruntimeOwnedStripWarning— 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
strictReadonlyWritesThe strip logs from inside
stripReadonlyFields/stripRuntimeOwnedFields;assertNoStrictDrops()(update) and theReadonlyFieldRejectedErrorthrow (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, realObjectQLplus a recording driver:locked_noteERR_READONLY_FIELD_REJECTEDaccount_numberERR_READONLY_FIELD_REJECTEDThe insert-side half was filed UNVERIFIED. It reproduces.
The card flagged
runtimeOwnedStripWarningas probably carrying the same claim whileinsertalso dischargesstrictReadonlyWrites(#5126 / #5503), and marked it unverified. Measured above: same code, same zero driver dispatches, same false sentence, on the same sequencing (engine.tsstrips at thestripRuntimeOwnedFieldscall, collectsautonumberDropped, 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/stripRuntimeOwnedFieldstakestrictReadonlyWrites, and the message composers take astrictoption 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.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.tsis 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
strictReadonlyWritesdown. Nothing else inengine.tsmoved.That each of the three is load-bearing is not asserted, it is measured — see the reverse verification below, where reverting
engine.tsalone 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 leftupdateManystill claiming a commit.Defect 2 — the remedy named
isSystembut neverpreserveAuditstripReadonlyFieldshonourscontext.preserveAudit(#3493) three lines above the log, a whitelist narrower thanisSystemby 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:preserveAuditdoes not rescue everything the strip takes: a non-auditsystemcolumn is stripped underpreserveAudittoo (no tenancy-forging backdoor). Measured onorganization_id: the flag is set, the field is stripped anyway, the line prints. AdvertisingpreserveAuditthere repeats The read-only strip still logs a WARN calling the addressed row's ownida 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.preserveAuditby-id update hands the primary key to the driver:isPreservableUnderAuditreinstates a platform object's ownid#8215 lands. The whitelist isAUDIT_TIMELINE_FIELDSplus any non-systemfield, and that second limb is under revision — ApreserveAuditby-id update hands the primary key to the driver:isPreservableUnderAuditreinstates a platform object's ownid#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 changesisPreservableUnderAudititself; ApreserveAuditby-id update hands the primary key to the driver:isPreservableUnderAuditreinstates a platform object's ownid#8215 stays untouched.The derivation is also safe by position: a field kept by
preserveAuditnever 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:readonlyStripWarning, strict:For a field the whitelist would not keep (
organization_id), thepreserveAuditsentence is absent from both andisSystemis still offered.runtimeOwnedStripWarning, default:runtimeOwnedStripWarning, strict: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, atwarn, 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.tsonly. 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.tsonly. Predicted red in a different place: the pure unit pins inrule-validator.test.tsstay 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 inengine-strict-readonly-warning-truthful.test.ts,rule-validator.test.tsfully 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 0pnpm --filter @objectstack/objectql test— 194 files, 3436 tests, all passingtscvia its own tsconfigexclude, sotypecheckdoes 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 toorigin/main.node scripts/pm/dispatch-gates.mjsover 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-freshexits 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/objectqlonly. Nopackages/spec, no docs, no ADR, nothing undercontent/docs/releases/.record-validator.tsuntouched (#7501's reservation).isPreservableUnderAudituntouched (#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.patchchangeset: observable log behaviour changes.Generated by Claude Code