Repository navigation
fix(objectql): a read answers a selected declared column the driver did not return as null (#29408) - #29528
Conversation
…id not return as null Driver-mongodb and driver-memory return no key for a declared field that was never written, while driver-sql answers its column as null. The engine now answers every stored declared column the read selected (the whole record, or the columns an explicit projection names) as null when the driver returned no key for it. Unselected columns, formula fields, names outside the declared set and a federated object's unprovisioned injected columns stay absent; a column field-level security refuses is still deleted by the security middleware's mask, which runs after the verb's body. Claude-Session: https://claude.ai/code/session_01ADHkmRKR6AV3yXf6HimS9d Co-authored-by: Claude <noreply@anthropic.com>
…ory and mongo, and FLS refusal as absent Claude-Session: https://claude.ai/code/session_01ADHkmRKR6AV3yXf6HimS9d Co-authored-by: Claude <noreply@anthropic.com>
…column now read null Five expand cases in engine.test.ts read a related record whose test-double rows carry no timestamps, and the formula-projection CONTROL reads a stored row without the registry-injected queue column: each now answers those declared columns null. Claude-Session: https://claude.ai/code/session_01ADHkmRKR6AV3yXf6HimS9d Co-authored-by: Claude <noreply@anthropic.com>
…he objectql sparse driver Claude-Session: https://claude.ai/code/session_01ADHkmRKR6AV3yXf6HimS9d Co-authored-by: Claude <noreply@anthropic.com>
…ot implement Claude-Session: https://claude.ai/code/session_01ADHkmRKR6AV3yXf6HimS9d Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 20 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin db902bf44e893755da8a875c5748fcd7be2b820e && git checkout db902bf44e893755da8a875c5748fcd7be2b820e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2420778c763cf5eaa92cf559582ecdf70d6274e5 b55451a4568c13e077880b6a1dec7281f0b2a684 && git checkout -B drift-repro 2420778c763cf5eaa92cf559582ecdf70d6274e5 && git merge --no-ff b55451a4568c13e077880b6a1dec7281f0b2a684
node scripts/docs-audit/affected-docs.mjs --json 2420778c763cf5eaa92cf559582ecdf70d6274e5
|
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head b38c236 · 2026-11-02T17:52Z |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract reviewServed-tier: Inputs read: card #29408 (body and all 5 comments: triage 6580439865, claims 6581219297 / 6581272888, dev report 6584231973), PR #29528 (body, 9-file list, net diff against ① Derived judgmentsThe diff changes no accept set: nothing a request may send is refused or newly admitted, and no request validation, projection check, error code or published type moves. The changes are all on the record body a read answers:
② Semver level
③ Boundary flags
Must-fix:
Should-fix:
Implemented-by: VERDICT: FAIL Stamped 2026-11-02T18:09Z. |
…ead-back A read now answers every selected declared column, null where nothing was stored (#29408), so an engine read-back cannot tell a key the read-audit writer stamped from a column it never wrote. The #9539 guard takes its key set from the stub driver's stored row; both assertions stay strict. Claude-Session: https://claude.ai/code/session_01ADHkmRKR6AV3yXf6HimS9d Co-authored-by: Claude <noreply@anthropic.com>
…ys absent on a read created_at / updated_at count as provisioned because driver-sql builds them on every table it creates. The platform builds no table for a federated (external) object, so on one with systemFields false or audit false nothing stores them, and a read must not answer them null. A timestamp the author declares (maps from the remote) is a stored column and is still answered. Pinned in selected-declared-columns-answered.test.ts; census row added. Claude-Session: https://claude.ai/code/session_01ADHkmRKR6AV3yXf6HimS9d Co-authored-by: Claude <noreply@anthropic.com>
…nst null on mongo/memory Claude-Session: https://claude.ai/code/session_01ADHkmRKR6AV3yXf6HimS9d Co-authored-by: Claude <noreply@anthropic.com>
…-p5djpl Claude-Session: https://claude.ai/code/session_01ADHkmRKR6AV3yXf6HimS9d Co-authored-by: Claude <noreply@anthropic.com>
The test tsconfig rejects the excess property on the inline literal (TS2353); a named fixture carries it. Claude-Session: https://claude.ai/code/session_01ADHkmRKR6AV3yXf6HimS9d Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head b55451a · 2026-11-02T21:36Z |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract reviewServed-tier: Round 2. Round 1 (issuecomment-6584622486, head Inputs read: card #29408 (body and all 5 comments: triage 6580439865, claims 6581219297 / 6581272888, dev reports 6584231973 and 6589029634), PR #29528 (body, 10-file list, net diff against ① Derived judgmentsThe diff still changes no accept set. Nothing a request may send is refused or newly admitted, and no validation, projection check, error code or published type moves. Every change is to the record body a read answers. Round-1 judgments ①1 to ①9 and ①11 hold on this head:
② Semver level
③ Boundary flags
Round-1 findings: M1 closed (①1). S1 closed (①3). S2 closed (①2). Must-fix: none. Should-fix: none. Implemented-by: VERDICT: PASS Stamped 2026-11-02T21:42Z. |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY 席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | Draft, base main, Fixes #29408. 10 files: selectedStoredColumns beside withDeclaredColumnsOnly in packages/objectql/src/declared-read-columns.ts, the engine find / findOne call sites, objectql / runtime / driver-mongodb tests, the plugin-audit #9539 guard fix, and an @objectstack/objectql patch changeset (Clause-②: no, Metadata-protocol: none). No packages/spec/**. Not governed (check-governed-merges governed: false). |
| Rule | A read answers every stored declared column it selected as null when the driver returned no key. Unselected, formula, undeclared, federated-unprovisioned and FLS-refused columns stay absent, and so do created_at / updated_at on a federated object whose field map does not hold them. Every value a driver returned is answered as before. A formula over an unwritten field on mongo or memory now evaluates against null, as on SQL; the changeset says so. |
| Rulings | OQ1 A: no new driver-memory pin (the census refuses a new consumer; the engine seam is driver-agnostic). OQ2 B: the sparse write response is follow-up #29538, built after this lands. |
| Evidence | Base aca58cdb measured on the engine: SQLite null, memory and mongo absent key. Round 2: M1 ablation red then restored; S2 ablation 3 red then restored; 27 packages that boot a real ObjectQL pass (only plugin-audit needed a change); dogfood 3277 passed, 0 failed, after merging main (the earlier 4 dogfood reds were base, fixed by #29451); gates 68/68. CI on this head: 52 runs, 37 green, 15 skipped, none red. check-expected-skips OK (15, all in main's roster). Real mongod not measured; the fill runs in the engine after any driver answers, so the fake Db is accepted. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay. |
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY
Requested by Steve · project thread
Fixes #29408
Clause-②: no — nothing a request may send is refused or newly admitted; measured on base and head, every read returns the same rows, and every value a driver returned is answered as before. A record body gains a
nullkey for a stored declared column the read selected and the driver returned no key for, and on driver-mongodb and driver-memory aformulaover such a column now evaluates against thatnull, as it already did on driver-sql.Metadata-protocol: none
维护者速读
find/findOne)对"本次读取选中的已声明列"补齐键:驱动没返回该键时答null,mongo / memory 与 SQL 的同一条记录读出来一样了。null,mongo / memory 缺键),客户端has(record.x)结果随驱动变;skills(objectstack-formula): "every predicate reads a record total over declared fields" contradicts the canonical sparse action-binding rule (declared-fields.ts, #4953), so agents drop the has() guard a list-row action needs #29094 裁决选项 A 的后续 D.1。null的键;mongo / memory 上引用未写字段的公式,过去因缺键出错而答null,现在按null计算,与 SQL 一致。未选中的列、FLS 拒读的列、公式字段本身、未声明的名字、联邦对象无存储的注入列和未声明的时间戳都仍然缺席。回滚 = revert 本 PR。packages/objectql/src/declared-read-columns.ts(selectedStoredColumns),packages/objectql/src/engine.ts两处调用,packages/runtime/src/declared-column-null-on-read.test.ts(FLS 钉)。pnpm --filter @objectstack/objectql exec vitest run src/selected-declared-columns-answered.test.ts,预期 18 条全绿。What changes
declared-read-columns.tsgains the declared-set twin ofwithDeclaredColumnsOnly:selectedStoredColumns(schema, projection)(the stored declared columns a read selected: the whole declared set with no projection, else the declared columns the projection names) andwithSelectedColumnsAnswered/rowsWithSelectedColumnsAnswered(answer each selected column the row does not carry asnull; never mutate the driver's row).engine.tscalls it once per read verb, right after the existing#21571shaping, on the projection the DRIVER was handed (driverAst.fields), so abeforeFindrewrite counts and the engine's own formula / file-owner widening is cut back off the answer as before.formulafield itself (virtual, nevernull-filled; it is computed after the fill, see Who sees it); a name outside the declared set; an injected column a federated (external) object does not provision (unprovisionedInjectedColumns); on a federated object, acreated_at/updated_atthe object neither declares nor has injected (systemFields: falseorsystemFields.audit: false):PLATFORM_PROVISIONED_COLUMNScounts them because driver-sql builds them on every table it creates, and the platform builds no table for a federated object (isFederatedObject), so only a declared (remote-mapped) timestamp is stored; a column field-level security refuses (the security middleware's mask deletes it after the verb's body, as before).find/findOneshares it:GET /data/:object/:idandPOST /data/:object/query/ the list route (protocol-record-reads.ts,getData/findData),expandsub-reads (expandRelatedRecordscallsthis.find), export and search.readUpdatePriorRow(the update's prior-row read) and the write echoes (insert / update / delete answers) are not changed.@objectstack/objectqlpatch changeset, with who sees it and how.Who sees it
nullinstead of a missing key, sohas(record.x)is nowtruefor it, as it already was on SQL. Test emptiness withrecord.x != null.formulaover such a field, on those two drivers, changes value. It used to fault on the missing key and answernull. Now it evaluates againstnulland answers what driver-sql answered:record.qty != null ? record.qty * 2 : -1readnullbefore and reads-1now (measured with the fill disabled atfindOne:null).systemFields: falseorsystemFields.audit: falseis unchanged on every driver: it gains nocreated_at/updated_atkey, including on driver-sql, which never returned one.Tests
packages/objectql/src/selected-declared-columns-answered.test.ts(18): a sparse driver (stores only written keys, projects as MongoDB does — the driver-memory / driver-mongodb shape) throughfind,findOne,getDataandfindData; negative pins for not-selected, outside-the-declared-set, federated injected columns, and a federated object's undeclared timestamps (systemFields: falseandsystemFields.audit: false, on whole, list and naming reads); a federated object's DECLAREDupdated_atis answerednull; a localsystemFields: falseobject keeps the provisioned three; a formula over an unwritten input evaluates to-1; stored row never mutated.packages/runtime/src/declared-column-null-on-read.test.ts(6): driver-sql (SQLite) withSecurityPlugincomposed and a member deniedsecret: whole read, list read, naming projection answernote: null; a non-naming projection leaves it absent; the FLS-refusedsecretstays absent on every read shape; a system read of the same row answerssecret: null(control).packages/drivers/driver-mongodb/src/mongodb-declared-column-null-on-read.test.ts(4): driver-mongodb through the engine on its fakeDb(no server, see Verification).null:engine.test.ts, Expand Related Records, five cases (should expand lookup fields by replacing IDs with full objects,… a user field …,… nested expand where-filter …,… master_detail fields,… only fields specified in the expand map …): the expanded record's expectation gainscreated_at: null, updated_at: null.engine-formula-projection-trim.test.ts,CONTROL: a read with no projection is unchanged(find and findOne): gainsqueue: null(registry-injected, absent from the stored fixture).federated-injected-column-readers.test.ts(not a moved expectation, census rows the gate requires): two rows, for the newselectedStoredColumns :: unprovisionedInjectedColumns()andselectedStoredColumns :: isFederatedObject()seams, both dispositionexcludes.plugin-auditsys-audit-log-record-views-columns.test.ts(therecord_viewslists anip_addresscolumn that no read-path writer ever stamps — a declared-but-unwritten column on a shipped compliance view #9539 guard): it learned the read-audit writer's stamped keys from an engine read-back, which now carriesip_address: null(a declared, unwritten column). It now takes them from the stub driver's stored row. Both assertions stay strict: theit.eachover therecord_viewscolumns andip_address specifically stays out.Acceptance notes
check:driver-memory-census(a maintainer ruling, driver-memory census inundeclared-field-write-driver-split.integration.test.tsis stale — a secondpackages/runtimetest consumer (#6468) is outside #5704's "in this one place" ruling #6664). Its shape is pinned by the objectql sparse driver, and the base reading below was taken with a probe that is not kept.null). Out of this card's read scope; seat 1 files the follow-up.Verification
Step 0 — base (`aca58cdb6`) reading, row inserted as `{ name }` on an object that also declares `note`
Db)note: nullfields: [name, note]note: nullfields: [name]note: nullfields: [name, note]note: nullReal mongod: NOT MEASURED. The conda-forge MongoDB 8.3.7 package downloaded and matched its published sha256 (
eaa4aba5…), but running it was refused in this session, so mongo was read through the driver's own fakeDb, whose projection answers as mongod does (named keys the document has, no key for one it lacks).Ablations (each: mutate through `ablation-replace.mjs`, red, restore, `git diff HEAD` empty)
selectedStoredColumnsreturns the whole stored set):selected-declared-columns-answered.test.ts3 red (NOT SELECTED …, OUTSIDE THE DECLARED SET …, own-key-undefined), restored.find's exit, after the security mask (runtime reads objectqldist/: rebuilt,ablation-dist-preflightmarker present in 4 built files; restore leg rebuilt, marker absent from all 14):declared-column-null-on-read.test.ts2 red (FLS-REFUSED stays absent …,NOT SELECTED stays absent …), green leg 6/6.ip_address(read-audit.tsbuildRow):sys-audit-log-record-views-columns.test.ts1 red (ip_address specifically stays out …), restored. The guard reads the stored row, so it still sees a stamp.selected-declared-columns-answered.test.ts3 red (UNPROVISIONED TIMESTAMP stays absent …forsystemFields: falseand forsystemFields.audit: false, and the declared-updated_atcase, whosecreated_atmust stay absent), restored.findOneonly. The formula case answersnull(expected-1), which is the pre-change value on a sparse driver. Restored.Suites (round 2: suites and dogfood on merge head `a3cdd97a`, which includes main's #29451; gates, typecheck and lint on final head `b55451a4`, whose one later commit is a test-file type fix)
turbo run build, dogfood closure plus every package below): exit 0.ObjectQL(the sparse-test-driver read-back sweep), eachpnpm --filter <pkg> test, all exit 0:pnpm --filter @objectstack/dogfood test, under the lock): exit 0. Test Files 397 passed / 3 skipped (400); Tests 3277 passed / 28 skipped (3305), 0 failed. The round-1flow-runasanddeclarative-endpoint-anonymous-guestreds are gone after the merge (test(dogfood): re-point the runAs and anonymous-guest flow pins to flow start admission's record rule #29451).pnpm --filter … typecheck, which includescheck:test-typecheck): objectql and plugin-audit exit 0 onb55451a4. CI'sType Check · workspaceona3cdd97awas red on objectql's test typecheck (TS2353 on an inlinesystemFields: falsefixture), fixed inb55451a4.--no-inline-configon the four round-2 files): exit 0.dispatch-gates --commandsonb55451a4): 68/68 exit 0;--ranreconciliation: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01ADHkmRKR6AV3yXf6HimS9d