Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): a preserveAudit by-id update no longer hands the primary key to the driver - #8319
Conversation
…primary key (#8215) isPreservableUnderAudit read every platform object's id as an author-declared business field (readonly: true, no system flag), so a historical-import (preserveAudit) by-id update kept id through the readonly strip and the driver received SET id = 'rec_1' WHERE id = 'rec_1' — a no-op on SQL, an outright rejection on stores with immutable primary keys. The REST ingress folds the path id into every update body (#6479), so a bulk import hit this without ever sending an id. The predicate now excludes the primary key (SystemFieldName.ID) before consulting the whitelist: the row's address is not a fact a historical import restores, and the strip protects that column for a store-portability reason, not an authorship one. The audit/timestamp family and genuine business readonly fields are still preserved; the shared predicate also stops an autonumber primary key from being seeded under a preserveAudit insert, while business autonumber identifiers stay preservable (#5503). The one-clause doc amendment to state-machine.mdx rides along so the prose and the code describe the same whitelist (the prose was already the narrower, correct half). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy
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:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8215
What
isPreservableUnderAudit(packages/objectql/src/validation/rule-validator.ts) now excludes the row's own primary key before consulting thepreserveAuditwhitelist:idisreadonly: truewith nosystemflag (sys_user_preference:Field.text({ label: 'Preference ID', required: true, readonly: true })), so the second limb (def.system !== true) read it as an author-declared business field likeclosed_at. On apreserveAuditby-id update the strip KEPTid, and the driver receivedSET id = 'rec_1' WHERE id = 'rec_1'— a no-op on SQL, an outright rejection on stores with immutable primary keys. The REST ingress folds the path id into every update body (RESTPATCH /data/:object/:id:请求体里的标量id压过路径:id,存在性探测/OCC 判在一行、写落在另一行、响应报第三个说法 #6479), so a bulk historical import hit this without ever sending aniditself.falseforSystemFieldName.IDbefore either limb. The primary key is the ADDRESS of the write, not a fact a historical import restores (data import: a "historical" import can't preserve original timestamps / audit fields — updated_at is stamped now, readonly fields stripped on upsert (#3479 follow-up) #3493 scoped the flag to "reinstate the original timeline"), and the strip protects that one column for a store-portability reason, not an authorship one.Keyed on
SystemFieldName.ID(@objectstack/spec/system) — the registry's sanctioned "canonical spelling of the column that plays role X" use — because there is no per-fieldprimaryKeymarker in the spec for a def-based test to read: the driver provisionsidas the primary key on every physical table, unconditionally (resolveInjectedSystemColumnsreports it even undersystemFields: false), and the engine's whole by-id addressing (idAddressesThisRow,addressKey: 'id') reads that name. TheprimaryKey: truespellings in test fixtures are inert runtime decoration (register-object-authored-shape.pin.tspins that the key is not a spec Field key).Both directions pinned
stripReadonlyFields: a readonlyidis stripped even underpreserveAudit, with and withoutaddressKey(and withaddressKey: 'id'the address stays out of the LOG as well as the payload — 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 composes).created_*/updated_*) and author-declared businessreadonlyfields (closed_at) still ride through beside the strippedid, andorganization_idstays stripped and loud (no tenancy backdoor).stripRuntimeOwnedFields(shared predicate, insert side): anautonumberprimary key seeded under apreserveAuditinsert is now stripped; the businessautonumberidentifier (account_number) remains preservable — [17.0-rc2验收] autonumber 字段可被普通调用者改写:POST 提交显式值绕过序列、PATCH 直接改号落库 —— readonly 剥离不保护 type:'autonumber' #5503's actual case.{ context: { preserveAudit: true } }for a strippedid— post-fix the flag would not keep it, so offering it would be thereadonlyStripWarningpromises a commit that strict mode refuses, and omits thepreserveAuditremedy its own strip implements #8214 false-remedy shape.preserveAuditAppliesderives from the same predicate, so the remedy text moved with the verdict, by construction.The pin this card exists to turn red
The #8216 pin ("composes with preserveAudit rather than overriding it") captured pre-fix behaviour as observed, deliberately. Rewritten as "composes with preserveAudit — and the primary key is stripped even under the flag (#8215)", pointed at #8215 rather than #6435, and its load-bearing facts are asserted as literals (
toHaveLength,toContainon field-name substrings, a literaloutobject) rather than derived from the composer, so a reworded message cannot keep both sides green.Doc clause (cross-seat:
content/docs/**,domain:devx#6023 — declared on the card by the PM)content/docs/protocol/objectql/state-machine.mdx:113already described the narrower whitelist ("author-declared businessreadonlyfields"); one clause is added so the prose stays the correct half instead of inverting into the loose half: "— but never the record's own primary key (id), which is the address of the write rather than a fact being restored (#8215)."Blast radius, measured
@objectstack/objectql: full suite 196 files / 3454 tests green;tsc --noEmitgreen.@objectstack/rest(read and run, not edited —domain:cli[PM seat] domain:cli — 🟢 os-zhuang #6024's package): full suite 109 files / 1809 tests green; the three named surfaces re-run verbose:import-runner-historical.test.ts(data import: mid-lifecycle rows rejected by state_machine.initialStates — historical-data migration blocked (#3433 sibling) #3479/data import: a "historical" import can't preserve original timestamps / audit fields — updated_at is stamped now, readonly fields stripped on upsert (#3479 follow-up) #3493),import-runner-historical-readonly-insert.test.ts([观察]stripReadonlyForInsert完全不读preserveAudit——readonly的历史导入豁免在 INSERT 侧是 declared ≠ enforced #6640),import-job-integration.test.ts— 20/20 green. Their fixtures either declareidwithoutreadonly(the strip never examines it) or exerciseclosed_at.@objectstack/metadata-protocol(owns the RESTPATCH /data/:object/:id:请求体里的标量id压过路径:id,存在性探测/OCC 判在一行、写落在另一行、响应报第三个说法 #6479 fold and the [观察]stripReadonlyForInsert完全不读preserveAudit——readonly的历史导入豁免在 INSERT 侧是 declared ≠ enforced #6640 insert-ingress warn): full suite 77 files / 1117 tests green.@objectstack/runtime:preserve-audit-real-driver.integration.test.ts(real SQLite end-to-end) 3/3 green, re-run against the restored fix.stripReadonlyForInsert, 安全/设计:静态 readonly 的 INSERT 豁免让审批/状态字段可在创建时被直接播种(比 #3003 少一步) #3043) is untouched:preserveAuditstatic-readonly relaxation is update-only ([观察]stripReadonlyForInsert完全不读preserveAudit——readonly的历史导入豁免在 INSERT 侧是 declared ≠ enforced #6640), and the ingress warn lists stripped fields without re-deriving this predicate — no drift copy exists.rule-validator.tsrestored toorigin/mainunder the new tests — measured 5 failed / 163 passed, the five being exactly the ApreserveAuditby-id update hands the primary key to the driver:isPreservableUnderAuditreinstates a platform object's ownid#8215 cases; the fix was then restored from the commit and proven byte-identical (git hash-objectequals the HEAD blob).Out of ruled scope, unchanged
ida forged caller write, on every single-record update of a platform object #8141's byte-identical payload-to-driver invariant holds for every non-preserveAuditpath; the address-silence rule is unchanged.idis NOT readonly still carriesidinto the SET clause — removing it there isupdate的 **by-id** 路径同样把非标量data.id交给驱动写主键列(#6262 的孪生形状,where.id胜出时) #6435's explicitly separate decision.update的 **by-id** 路径同样把非标量data.id交给驱动写主键列(#6262 的孪生形状,where.id胜出时) #6435's truthy-scalardata.iddispatch rules are untouched.Changeset: patch for
@objectstack/objectql(behaviour fix on a documented flag; nothing authorable removed, so no ADR-0087 entry — the changeset is not declared-breaking).Generated by Claude Code