Skip to content

fix(objectql): a read answers a selected declared column the driver did not return as null (#29408) - #29528

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-p5djpl
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-p5djpl

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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 null key for a stored declared column the read selected and the driver returned no key for, and on driver-mongodb and driver-memory a formula over such a column now evaluates against that null, as it already did on driver-sql.
Metadata-protocol: none

维护者速读

  • 改了什么:引擎读路径(find / findOne)对"本次读取选中的已声明列"补齐键:驱动没返回该键时答 null,mongo / memory 与 SQL 的同一条记录读出来一样了。
  • 为什么改:同一条记录按驱动读出两种形状(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.ts gains the declared-set twin of withDeclaredColumnsOnly: selectedStoredColumns(schema, projection) (the stored declared columns a read selected: the whole declared set with no projection, else the declared columns the projection names) and withSelectedColumnsAnswered / rowsWithSelectedColumnsAnswered (answer each selected column the row does not carry as null; never mutate the driver's row).
  • engine.ts calls it once per read verb, right after the existing #21571 shaping, on the projection the DRIVER was handed (driverAst.fields), so a beforeFind rewrite counts and the engine's own formula / file-owner widening is cut back off the answer as before.
  • Stays absent: a column the projection does not name; a formula field itself (virtual, never null-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, a created_at / updated_at the object neither declares nor has injected (systemFields: false or systemFields.audit: false): PLATFORM_PROVISIONED_COLUMNS counts 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).
  • Every door on find / findOne shares it: GET /data/:object/:id and POST /data/:object/query / the list route (protocol-record-reads.ts, getData / findData), expand sub-reads (expandRelatedRecords calls this.find), export and search. readUpdatePriorRow (the update's prior-row read) and the write echoes (insert / update / delete answers) are not changed.
  • @objectstack/objectql patch changeset, with who sees it and how.

Who sees it

  • A client of an object served by driver-mongodb or driver-memory whose records have unwritten declared fields. Such a field now arrives as a key holding null instead of a missing key, so has(record.x) is now true for it, as it already was on SQL. Test emptiness with record.x != null.
  • A formula over such a field, on those two drivers, changes value. It used to fault on the missing key and answer null. Now it evaluates against null and answers what driver-sql answered: record.qty != null ? record.qty * 2 : -1 read null before and reads -1 now (measured with the fill disabled at findOne: null).
  • A federated object with systemFields: false or systemFields.audit: false is unchanged on every driver: it gains no created_at / updated_at key, including on driver-sql, which never returned one.
  • A write's stored row and a write's echo (the insert / update answer) are unchanged.

Tests

  • New 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) through find, findOne, getData and findData; negative pins for not-selected, outside-the-declared-set, federated injected columns, and a federated object's undeclared timestamps (systemFields: false and systemFields.audit: false, on whole, list and naming reads); a federated object's DECLARED updated_at is answered null; a local systemFields: false object keeps the provisioned three; a formula over an unwritten input evaluates to -1; stored row never mutated.
  • New packages/runtime/src/declared-column-null-on-read.test.ts (6): driver-sql (SQLite) with SecurityPlugin composed and a member denied secret: whole read, list read, naming projection answer note: null; a non-naming projection leaves it absent; the FLS-refused secret stays absent on every read shape; a system read of the same row answers secret: null (control).
  • New packages/drivers/driver-mongodb/src/mongodb-declared-column-null-on-read.test.ts (4): driver-mongodb through the engine on its fake Db (no server, see Verification).
  • Forced test edits, each because a test-double row lacks a declared column that a whole read now answers 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 gains created_at: null, updated_at: null.
    • engine-formula-projection-trim.test.ts, CONTROL: a read with no projection is unchanged (find and findOne): gains queue: 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 new selectedStoredColumns :: unprovisionedInjectedColumns() and selectedStoredColumns :: isFederatedObject() seams, both disposition excludes.
    • plugin-audit sys-audit-log-record-views-columns.test.ts (the record_views lists an ip_address column 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 carries ip_address: null (a declared, unwritten column). It now takes them from the stub driver's stored row. Both assertions stay strict: the it.each over the record_views columns and ip_address specifically stays out.

Acceptance notes

Verification

Step 0 — base (`aca58cdb6`) reading, row inserted as `{ name }` on an object that also declares `note`
read SQLite memory mongo (fake Db)
findOne, no projection note: null no key no key
findOne fields: [name, note] note: null no key no key
findOne fields: [name] no key no key no key
find, no projection note: null no key no key
find fields: [name, note] note: null no key no key

Real 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 fake Db, 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)
  • A1 projection ignored (selectedStoredColumns returns the whole stored set): selected-declared-columns-answered.test.ts 3 red (NOT SELECTED …, OUTSIDE THE DECLARED SET …, own-key-undefined), restored.
  • A2 declared intersection removed: 1 red (OUTSIDE THE DECLARED SET stays absent), restored.
  • A4 federated exclusion removed: 1 red (FEDERATED stays absent), restored.
  • A3 fill moved to find's exit, after the security mask (runtime reads objectql dist/: rebuilt, ablation-dist-preflight marker present in 4 built files; restore leg rebuilt, marker absent from all 14): declared-column-null-on-read.test.ts 2 red (FLS-REFUSED stays absent …, NOT SELECTED stays absent …), green leg 6/6.
  • A5 (round 2, M1) the read-audit writer stamps ip_address (read-audit.ts buildRow): sys-audit-log-record-views-columns.test.ts 1 red (ip_address specifically stays out …), restored. The guard reads the stored row, so it still sees a stamp.
  • A6 (round 2, S2) the federated timestamp exclusion removed: selected-declared-columns-answered.test.ts 3 red (UNPROVISIONED TIMESTAMP stays absent … for systemFields: false and for systemFields.audit: false, and the declared-updated_at case, whose created_at must stay absent), restored.
  • S1 measurement: the fill disabled at findOne only. The formula case answers null (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)
  • Closure build (turbo run build, dogfood closure plus every package below): exit 0.
  • Packages whose tests boot a real ObjectQL (the sparse-test-driver read-back sweep), each pnpm --filter <pkg> test, all exit 0:
    • objectql 535 files / 10409 tests; rest 373 / 7057 passed, 332 skipped; runtime 525 / 8490 passed, 19 skipped; driver-mongodb 34 passed, 5 skipped / 725 passed, 190 skipped.
    • plugin-audit 68 / 1109; plugin-security 241 / 5200 passed, 45 skipped; plugin-auth 212 / 3655 passed, 10 skipped; plugin-approvals 107 / 1381; plugin-sharing 60 / 1316; plugin-webhooks 25 / 258; plugin-email 48 / 785; plugin-pinyin-search 4 / 39; organizations 18 / 248.
    • service-analytics 208 / 4950 passed, 282 skipped; service-messaging 63 / 771; service-storage 71 / 1326; service-settings 67 / 1120; service-automation 262 / 3395; service-datasource 70 / 1160; service-mailbox 10 / 152.
    • cli 564 passed, 1 skipped / 6885 passed, 9 skipped; client 54 / 693; driver-odata 9 / 1383; connector-mcp 8 / 102; cloud-connection 67 / 813; example-showcase 45 / 541; example-crm 7 / 48.
  • dogfood battery (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-1 flow-runas and declarative-endpoint-anonymous-guest reds are gone after the merge (test(dogfood): re-point the runAs and anonymous-guest flow pins to flow start admission's record rule #29451).
  • typecheck (pnpm --filter … typecheck, which includes check:test-typecheck): objectql and plugin-audit exit 0 on b55451a4. CI's Type Check · workspace on a3cdd97a was red on objectql's test typecheck (TS2353 on an inline systemFields: false fixture), fixed in b55451a4.
  • lint (root eslint --no-inline-config on the four round-2 files): exit 0.
  • derived gates (dispatch-gates --commands on b55451a4): 68/68 exit 0; --ran reconciliation: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01ADHkmRKR6AV3yXf6HimS9d

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 4 documentable anchor(s).

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2420778c763cf5eaa92cf559582ecdf70d6274e5.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 20 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2420778c763cf5eaa92cf559582ecdf70d6274e5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from db902bf44e893755da8a875c5748fcd7be2b820e — the merge of head b55451a4568c13e077880b6a1dec7281f0b2a684 into base 2420778c763cf5eaa92cf559582ecdf70d6274e5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2420778c763cf5eaa92cf559582ecdf70d6274e5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head b38c236 · 2026-11-02T17:52Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract review

Served-tier: TIER_DEFAULT
Head-sha: b38c236021b01ebd317aa863b9e089b1d4776b43
Local-runs: none

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 main 5651647), the check-runs on the head. Cited code on main was read to judge the diff (declared-read-columns.ts, engine.ts find / findOne / applyFormulaPlan / withoutFormulaWidening, engine-secret-internal-fields.ts, plugin-security field-masker.ts, spec injected-system-column-provenance.ts and resolveInjectedSystemColumns).

① Derived judgments

The 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:

  1. A selected stored declared column that the driver did not return is now answered null on find / findOne. RIGHT. It is the card's "Wanted" exactly. The fill runs once per verb, right after the An unprojected REST data query returns ORPHANED columns that no metadata declares (fields retired in an upgrade), outside any field-level rule, until os migrate apply --allow-destructive #21571 shaping, on driverAst.fields. So a beforeFind rewrite counts. Every door on the two verbs inherits it: getData, findData / list, expand sub-reads (via this.find), export and search. withSelectedColumnsAnswered copies the row and never mutates it. On driver-sql this is a no-op for every local declared non-formula column (fieldHasColumn gives each one a column).
  2. A column the projection does not name stays absent. RIGHT. Only string entries the stored set holds are selected. Widening is not selecting: the formula-input and file-owner id keys are filled, then cut by rowsWithoutFormulaWidening / withoutFormulaWidening, which delete by key whatever the value is. Read on main, and pinned by the doubled case.
  3. A column field-level security refuses stays absent. RIGHT. FieldMasker.maskRecord runs in the security middleware's post-phase, after the verb body, and deletes every hidden field unconditionally. A partial-mask rule maps null to null (maskFieldValue), and so does maskSecretFields (row[field] == null ? null : SECRET_MASK). omitInternalFields deletes. Pinned on SQLite with SecurityPlugin composed (6 tests), and ablation A3 (fill moved after the mask) reds 2 of them.
  4. A formula field is not filled. RIGHT. But its VALUE changes on mongo / memory, and the PR does not declare that. Should-fix S1. applyFormulaPlan evaluates CEL against the raw row (record: scope), not a materialized one. At base, on a sparse driver, a formula that reads an unwritten declared field faults on the missing key and answers null, with a fault reported to the sink. On head it evaluates against null. The PR's own pin shows this: doubled = record.qty != null ? record.qty * 2 : -1 reads -1. At base it would read null plus a fault. The new behavior is the right one: it is SQL's answer and the materializeDeclaredFields IN-HAND rule. But the PR body and changeset claim "every read returns the same rows and the same values" and that a formula "keeps its computed value". That is not true on mongo / memory for any formula over an unwritten field, and has(record.x) inside a formula flips from false to true there too.
  5. An injected column that a federated object does not provision stays absent. RIGHT, as far as unprovisionedInjectedColumns reaches. A residual gap: should-fix S2. PLATFORM_PROVISIONED_COLUMNS (id, created_at, updated_at) joins every declaredColumnSet unconditionally. unprovisionedInjectedColumns lists only columns the registry injected. On an external object with systemFields: false or systemFields: { audit: false }, nothing is injected (spec pin: unprovisionedInjectedColumns({ ...external(), systemFields: false }) is []). So created_at / updated_at stay in the stored set, and a remote table without them is now answered created_at: null, updated_at: null. That happens on driver-sql too, where base answered no key. It is the very case the PR's own rule ("no storage behind it, so there is no persisted state to answer for") excludes, and it contradicts the declaration's "SQL unchanged". The value is only null and the reach is narrow (federated objects that opt out of system fields), so it is not a must-fix.
  6. A name outside the declared set is never added. RIGHT. The intersection runs with declaredColumnSet, so this cannot widen what An unprojected REST data query returns ORPHANED columns that no metadata declares (fields retired in an upgrade), outside any field-level rule, until os migrate apply --allow-destructive #21571 serves. Pinned, and ablation A2 reds it.
  7. In-process readers now see the total record on mongo / memory. RIGHT, and named in the changeset. These are afterFind hooks, expand targets, file-reference resolution, and flow get_record → later nodes. A hook or client that told absent from null ('x' in row, has(record.x)) now reads what SQL already answered. A GET → PUT round-trip client now sends explicit nulls and stores them where the key was absent. That is parity with SQL, and the accept set is unchanged.
  8. Not covered, rightly out of scope: count, aggregate (including its driver.find bucketing fallback) and distinct. They answer no record bodies. readUpdatePriorRow, the pre-image reads and the write echoes are unchanged, as the PR names.
  9. The write echo now diverges from the read on mongo / memory. An insert or update answers sparse, and the next GET answers total. On SQL both are total. hydrateWriteFormulas' read-your-write rule ("the write response is now the same materialization a read produces") is the precedent this leaves open. This is open_question 2, answered in ③.
  10. The PR breaks an in-process reader that tells absent from null, and CI shows it. WRONG: must-fix M1. Test Core (6/8) fails @objectstack/plugin-audit src/objects/sys-audit-log-record-views-columns.test.ts, case "ip_address specifically stays out — the read writer structurally cannot stamp it". The assertion at line 206 is expect(stampedKeys.has('ip_address')).toBe(false), which received true. That test derives "the keys the read writer stamped" by reading the persisted sys_audit_log row back through engine.find on a sparse stub driver. On head, that read now answers every declared sys_audit_log column, ip_address included, as null, so the key set is the declared set and not the stamped set. Its sibling it.each ("column %s is a key the read writer actually stamps") still passes, but it has become vacuous: it now passes for ANY declared column, so the record_views lists an ip_address column that no read-path writer ever stamps — a declared-but-unwritten column on a shipped compliance view #9539 guard no longer guards. This is caused by this diff, not inherited: the failing read is exactly the fill in ①1, and the dev's suite list (objectql, rest, runtime, driver-mongodb, driver-memory) does not include plugin-audit.
  11. Forced test edits in the diff. RIGHT. The five Expand cases and the formula-trim CONTROL each move because a test-double row lacks a declared column (created_at / updated_at / registry-injected queue) that a whole read now answers. None of them relaxes an assertion. The federated-injected-column-readers census row (excludes) is the right disposition for the new seam.

② Semver level

  • @objectstack/objectql: patch. RIGHT. A bug fix in a released package, non-protocol, with nothing BREAKING. The other two packages the diff touches (runtime, driver-mongodb) gain test files only and publish nothing.
  • Clause-②: no. RIGHT. No accept set moves (①). The response gains null keys, which is not a widening or a narrowing of what a request may send.
  • Metadata-protocol: none. RIGHT. Nothing a metadata author can write changes.
  • The changeset prose is WRONG on two sentences: "every read returns the same rows and the same values" (see ①4 and ①5), and "Who sees it", which omits formula values on mongo / memory. That is S1 and S2. The level is unaffected.

③ Boundary flags

  • Real mongod NOT MEASURED (fake Db). Accepted. The fill is driver-agnostic and runs in the engine after the driver answers. Real mongod only bears on the base premise (no key for an unwritten field), and the card already cites it from mongodb-driver.ts. The fake Db projects as mongod does: named keys the document has. Not escalated.
  • open_question 1 (driver-memory real-driver pin). Answer: A. A new consumer under check:driver-memory-census is a maintainer ruling (driver-memory census in undeclared-field-write-driver-split.integration.test.ts is stale — a second packages/runtime test consumer (#6468) is outside #5704's "in this one place" ruling #6664) that this card does not hold. The seam is driver-agnostic, and the objectql sparse driver reproduces the memory shape. No escalation is needed for this card.
  • open_question 2 (write echoes sparse on mongo / memory). Answer: B. The seat files a follow-up. The card's business reason (one record, two shapes) survives on the create / update response a client renders first. ①9 is now a read-vs-echo divergence on the same driver, against the hydrateWriteFormulas read-your-write precedent. Escalated to the seat to file.
  • Dogfood: 4 local failures claimed pre-existing. These are flow-runas ×3 and declarative-endpoint-anonymous-guest ×1. The dev's control (fill disabled at both call sites, objectql rebuilt) fails the same 4 of 12, which is a sound base-equivalence control. CI did not run the battery on this head: Dogfood Regression Gate = success, "satisfied — no shard was expected to run" (the filter skipped all 3 shards and Dogfood Verify CLI). So the gate's verdict is a filter skip, not a pass on this change. The dev's out_of_scope_findings entry goes to the seat to dedupe and file. Dogfood ran on 7daf9652, and the only later commit is a driver-mongodb test file outside the dogfood closure. Accepted.
  • Other deviations (the A3 rerun, the gate rerun on the final head, the PR opened without assignees): process only, accepted.
  • PR author is objectstack-fleet[bot], so there is no separate author note.
  • Check-runs on the head (read at 18:09Z, after waiting about 14 minutes): 33 success, 13 skipped, 1 failure, and the Test Core aggregate is still queued. The one failure is Test Core (6/8), the plugin-audit case in ①10 (M1). Every Lint / Type Check / Build / guard run is green.

Must-fix:

  • M1. Test Core (6/8) is red, caused by this change (①10). Re-derive stampedKeys in sys-audit-log-record-views-columns.test.ts from the stub driver's stored row, not from an engine read. That keeps both the ip_address pin and the per-column it.each meaningful again, rather than relaxing either one. Then run every package whose tests read records back through find / findOne on a sparse double, not only the four suites listed, and report them. Any other such pin is the same class of failure.

Should-fix:

  • S1. Correct the PR body and changeset declaration lines ("the same values" and the formula sentence). Add to "Who sees it": a formula reading an unwritten field now evaluates against null on mongo / memory (it used to answer null with a fault), as it already did on SQL. Pin the base → head value move if the follow-up touches it.
  • S2. On an external object, a PLATFORM_PROVISIONED_COLUMNS timestamp that the registry did not inject (systemFields: false or audit: false) should be left out of selectedStoredColumns, with a pin. This lands here or as a follow-up card.
  • Follow-up (open_question 2 → B): a card for the write echo on mongo / memory.

Implemented-by: claude/project-thread-p5djpl
Reviewed-by: session_01ADHkmQjPiXgNoSaEpqeGvY

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>
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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head b55451a · 2026-11-02T21:36Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract review

Served-tier: TIER_DEFAULT
Head-sha: b55451a4568c13e077880b6a1dec7281f0b2a684
Local-runs: none

Round 2. Round 1 (issuecomment-6584622486, head b38c2360) was FAIL with M1, S1 and S2. This record judges the whole PR on the new head, not only the deltas.

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 main), the round-1 record, and the 52 check-runs on the head. Cited code on main was read to judge the diff: declared-read-columns.ts, federated-object.ts (isFederatedObject), spec injected-system-column-provenance.ts (platformProvisionsStorage, unprovisionedInjectedColumns), driver-sql table creation and stampInsertTimestamps, and the insert-time timestamp stamping in driver-memory and driver-mongodb.

① Derived judgments

The 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: engine.ts has the same two call sites on driverAst.fields, and the fill, the projection and widening rule, the FLS ordering and the declared-set intersection are unchanged. The round-2 changes:

  1. M1 is closed. The record_views lists an ip_address column that no read-path writer ever stamps — a declared-but-unwritten column on a shipped compliance view #9539 guard guards again. RIGHT. sys-audit-log-record-views-columns.test.ts now builds stampedKeys from driver.storedRows('sys_audit_log'). That is a copy of the stub's stored map, so it holds exactly what driver.create received, before any read shapes it. The engine read stays only as a one-row count check. Both assertions are still strict: the it.each still checks stampedKeys.has(column) with toBe(true) over every record_views column, and ip_address is still checked with toBe(false), next to not.toContain('ip_address') on the view. On base, the read-back went through withDeclaredColumnsOnly on a stub that returns the row as stored. So the stored key set is the old observed set plus any undeclared key the writer passed, and every view column is a declared field. No assertion became weaker. The dev's ablation A5 (the writer stamps ip_address) turns the named case red. On CI, Test Core (6/8), the shard that failed in round 1, is green. The sweep that round 1 asked for was run: 23 more packages that boot a real ObjectQL all exit 0, and only plugin-audit needed a change. Test Core 8/8 agrees.
  2. S2 is closed. selectedStoredColumns no longer fills a federated object's undeclared platform timestamp. RIGHT. The new skip is federated && name !== 'id' && !hasOwn(fields, name). The only names in declaredColumnSet that are not field-map keys are PLATFORM_PROVISIONED_COLUMNS, so the skip can only drop created_at / updated_at on an external object whose field map does not hold them. isFederatedObject is external != null, which is the negation of the spec's platformProvisionsStorage, so it draws the same line as unprovisionedInjectedColumns. I checked each question:
    • id is still answered. It is exempt by name, and the pin's three read shapes assert row.id.
    • A declared remote timestamp is still answered null. A declared updated_at is a field-map key, so it is kept. The REMOTE_MAPPED case pins updated_at: null with created_at absent.
    • The default federated case is unchanged. There the registry injects the timestamps, they are field-map keys, and they are injected-unprovisioned, so unprovisionedInjectedColumns still excludes them. The FEDERATED stays absent case pins it.
    • The rule takes away no value a driver returned. It only shrinks the set that gets null-filled. withDeclaredColumnsOnly still serves any created_at a remote returns, for example one mapped through external.columnMap.
    • A non-federated object with systemFields: false or audit: false is not affected, and keeping its three columns is right. driver-sql creates created_at / updated_at on every table it builds (createAuditTimestampColumn, unconditional) and stamps them. driver-memory (memory-driver.ts create and bulk paths) and driver-mongodb (mongodb-driver.ts create, bulkCreate and the upsert $setOnInsert) stamp both on every insert, whatever systemFields says. So a local opted-out object has a stored timestamp on all three drivers. Only a row written outside those doors could read null, and there null is the honest answer for a provisioned column. It is pinned by a local object keeps the provisioned three whatever systemFields says.
    • Coverage. The pins cover systemFields: false and systemFields.audit: false, on whole, list and naming reads. The dev's ablation A6 (exclusion removed) turns 3 cases red. The census gains selectedStoredColumns :: isFederatedObject() with excludes, the right disposition.
  3. S1 is closed. The declaration now tells the formula change. RIGHT. The changeset's Clause-② line no longer says "the same values". It says "every value a driver returned is answered as before", which is true: a formula's value is the engine's, not the driver's. It also states that a formula over an unwritten column on mongo / memory now evaluates against null, as on SQL. "Who sees it" gives the before and after (null before, -1 now), and the dev measured the old value with the fill disabled. The PR body matches.
  4. The main merge brought in nothing new. RIGHT. a3cdd97a merges 390bd2ae. Comparing 390bd2ae...a3cdd97a gives exactly the PR's 10 files with the PR's own hunks, so the merge resolution added nothing. Main's commits that the merge brought in touch nothing in engine.ts, declared-read-columns.ts, the census test or plugin-audit. b55451a4 changes one test fixture's typing (an inline literal becomes a variable) and nothing else. Main has since moved 46 commits past 390bd2ae, including engine.ts, registry.ts and the census file. GitHub reports the PR as unstable, not dirty, so there is no textual conflict. The one new find consumer there, serveReferenceNames, tests == null, so it does not tell absent from null. This is for information only: the merge path re-tests it, and it is not a finding on this head.
  5. No new must-fix or should-fix was found on this head.

② Semver level

  • @objectstack/objectql: patch. RIGHT. It is a bug fix in a released, non-protocol package, and nothing is BREAKING. runtime, driver-mongodb and plugin-audit gain or change test files only and publish nothing. Check Changeset is green.
  • Clause-②: no. RIGHT. No accept set moves (①). The line now names the response's null keys and the formula value move without claiming that values are unchanged.
  • Metadata-protocol: none. RIGHT.

③ Boundary flags

  • Round-1 rulings stand. open_question 1 is A (no driver-memory census consumer). open_question 2 is B: the seat filed follow-up objectql write path: an insert or update response leaves a declared column the driver did not echo as an absent key on mongo/memory, while the next read answers it null (#29408) #29538 for the write echoes. The round-2 report has open_questions: [], and nothing is newly open.
  • Real mongod NOT MEASURED, still the driver's fake Db. Accepted, as in round 1. The fill is engine-side and driver-agnostic.
  • Round-2 deviations. These are the 2h background limit (example-crm and dogfood were re-run and passed) and the ablation-replace restore of an uncommitted edit (the fix was re-applied, committed and re-ablated). Both are process only and accepted.
  • Dogfood. On CI, Dogfood Regression Gate is success, but its 3 shards and Dogfood Verify CLI are skipped by the filter, so CI did not run the battery. The dev ran it locally on a3cdd97a under the lock: 3277 passed, 0 failed. The only later commit is an objectql test-file type fix, which is outside the dogfood closure. The round-1 flow-runas / anonymous-guest reds are gone after test(dogfood): re-point the runAs and anonymous-guest flow pins to flow start admission's record rule #29451. Accepted.
  • Check-runs on the head. I read them at 2026-11-02T21:38Z and again at 2026-11-02T21:41Z: 52 runs, all completed, 37 success, 15 skipped, 0 failure. Test Core 1/8 to 8/8 and the aggregate are green. Every Type Check run is green, including Type Check · workspace, which was red on a3cdd97a. Every Lint, Build and guard run is green, as are Check PR Size and Governed Surface Queue Guard. The skips are UI, opt-in and dogfood-filter jobs this diff does not reach.
  • PR author is objectstack-fleet[bot], so there is no separate author note.

Round-1 findings: M1 closed (①1). S1 closed (①3). S2 closed (①2).

Must-fix: none.

Should-fix: none.

Implemented-by: claude/project-thread-p5djpl
Reviewed-by: session_01ADHkmQjPiXgNoSaEpqeGvY

VERDICT: PASS

Stamped 2026-11-02T21:42Z.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

席位核对 · ACCEPT · domain:server seat 1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY · 2026-11-02T21:44Z

Head b55451a456, round-2 report 6589029634 (on #29408), claim 6581272888. Clause-②: no → declaration face at TIER_DEFAULT. Round 1 was record 6584622486, FAIL (M1, S1, S2). Round 2 is record 6589208171 on this head, PASS: every round-1 finding is closed, with no new must-fix or should-fix.

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.

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.

objectql read path: a declared column the driver did not return stays an absent key, so mongo/memory rows are sparse where SQL rows are not

1 participant