Skip to content

docs(spec): a cleared optional multi-value field is stored as null, in FieldSchema's multiple and required docs - #29763

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-ztk9p3
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-ztk9p3

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #29652

Clause-②: yes
Metadata-protocol: semantic
Skills-docs: none — git grep over skills/** for the empty-representation claim (never null, reads back as [], emptied) finds no page that teaches it; the hand-written content/docs/** pages that do are listed under the census below and move with the write-door PR that changes the behaviour

维护者速读

What changes

Text only, in packages/spec/src/data/field.zod.ts (measured on origin/main 1c1a5d40; the line numbers are unchanged at the final merge base 0b2b309c), plus one comment in filter-empty-operator.ts and the regenerated references. No key, default, accept set or export moves.

required doc block (≈:1314).

multiple doc block (≈:1357).

multiple describe (≈:1380). The rule only, no tracker id.

  • Before: "An emptied multi-value lookup reads back as [], never null — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair."
  • After: "A cleared optional multi-value field is stored as null, the same as one never set: an empty list [] or an empty string written to it stores null, and the rule binds every writer (cascade repair, form clears, API writes). Read null and [] alike as empty, as the is_empty filter ($empty) does, since a row stored before this rule can still hold []. On a required multi-value field the empty list is refused: required means a non-empty list."

filter-empty-operator.ts docblock (≈:91), the one other packages/spec hit.

  • Before: "a field whose value is a list is emptied to []."
  • After: "a field whose value is a list can hold [] (a cleared optional one stores null, but a row stored before that rule can still hold [])." The $empty table and expandEmptyOperator are untouched.

Generated. pnpm --filter @objectstack/spec build, then check:generated named one stale artifact (content/docs/references/**); gen:docs rewrote the multiple row in three pages, because FieldSchema is rendered in each: references/data/field.mdx, references/data/object.mdx (2 rows) and references/system/migration.mdx (2 rows). check:generated then reported all 14 artifacts current.

Changeset. .changeset/29652-multi-value-empty-null-docs.md: @objectstack/spec minor, both declaration lines at line start.

Why

Ruling A on #29343 (record 6591577985), verbatim: 「A:存 null,先修 #9447 spec 文本」 — 「spec 席先改 field.zod.ts 的 multiple/required 说明,再落本卡 PR(代码已建好),另开一张清理已存 '[]' 的 migrate 卡。」

The declarations, measured.

  • Metadata-protocol: semantic. The Check Changeset "WHICH LEVEL" text: "semantic — the meaning or default of an authorable key changes → minor". multiple is an authorable key, and what it promises about the stored empty value changes; the published describe is that promise. The counter-reading is the none row's "description or error text", which is for wording that leaves the meaning alone. So semantic, and @objectstack/spec takes minor.
  • Clause-②: yes, no arm. A published semantic contract changes, and no accept set moves, so neither (widening) nor (narrowing) applies.
  • ADR-0087. check-adr-0087-registration asks for a disposition only on a changeset that declares a breaking change; this one does not. The gate exits 0. No SemanticMigration entry is written. Stored '[]' values are the ruling's step 3, a separate os migrate data card.

Census: the "[], never null" claim

git grep on 1c1a5d40 for never null, never `null`, reads back as [], emptied, 9447, and multi-value near []:

  • packages/spec/**, amended here: field.zod.ts:1316-1317 (required docblock), :1357-1364 (multiple docblock), :1371 (multiple describe); filter-empty-operator.ts:91.

  • packages/spec/**, read and left: value-roundtrip-conformance.ts:216 (m_empty, "empty array must not degrade to null") is the driver round-trip table, below the write door, and the ruling does not change what a driver returns for a written []. The null-or-[] rows in filter-empty-operator.ts, filter.zod.ts:1802/:2948 and filter-logic-conformance.ts:80 are the unchanged $empty table. CHANGELOG.md:48935 is release-owned. The other never null hits (organization.zod.ts, solution-blueprint.zod.ts, tests) are other subjects.

  • skills/**: 0 hits.

  • Hand-written content/docs/**, findings, not edited here: each says an emptied multiple: true set is written or read back as [], never null, and most point at the multiple doc block as their source:

    They describe what the runtime does today, which changes only with the write-door PR on objectql: a multi-value unique column (multiselect, or lookup with multiple: true) cleared to [] on two records refuses the second with 409 UNIQUE_VIOLATION #29343. Moving them there keeps each page true on every merged commit.

Tests

No test changes: no tested behaviour moves. pnpm --filter @objectstack/spec test: on the final head 3e61ed73, under os-verify-lock: Test Files 803 passed (803), Tests 23795 passed, 1 todo. pnpm --filter @objectstack/spec typecheck ran in the same chain, lock VERDICT command-exit 0. The two earlier heads read the same: f05af6cb (803/803 files, 23786 passed) and 8b1ecbbc after the first origin/main merge (803/803, 23792 passed).

Acceptance notes

Verification

Every reading here is on the final head 3e61ed73: the branch merged with origin/main at 0b2b309c. The delta against main is exactly the 6 files above, 45 insertions and 20 deletions.

  • pnpm exec turbo run build --concurrency=2: 118/118 tasks, lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/spec check:generated: "All 14 generated artifacts are up to date".
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 96 families from 6 changed paths. Each ran serially and was recorded as CMD :: exit N: 96 exit 0. dispatch-gates --ran reported "96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN".
  • GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base origin/main --event FILE, with this body as the event: exit 0. Its verdict: "LEVEL AXIS: this PR declares clause-② yes and metadata-protocol semantic, and no package whose published source … it moves is graded patch".
  • check-adr-0087-registration --base origin/main: exit 0. No changeset here declares a breaking change, so the gate asks for no disposition.
Earlier heads

On f05af6cb (before any merge), the same 96 ran: 89 exit 0 and 7 exit 3 (PREREQUISITE NOT MET). The 7 lacked a built dist for @objectstack/formula, @objectstack/lint, @objectstack/client-react and @objectstack/objectql, or needed the check-plugin-teardown-shape fixture commit that the shallow clone was missing. After a full build and a fetch of that fixture commit, all 7 exited 0. On 8b1ecbbc (after the first merge), all 96 exited 0.

Not run locally, left for CI: pnpm lint, the test:repo project, and the full dogfood suite (the title has no ! and the PR declares no (narrowing) arm).


Generated by Claude Code

Amend the FieldSchema `multiple` and `required` doc blocks and the
`multiple` describe to the ruled empty representation: a cleared
optional multi-value field stores null at the write door, `[]` and `''`
store null, `required` still means a non-empty list, and the `$empty`
table is unchanged.

Claude-Session: https://claude.ai/code/session_01WGufzeW5cprrZRccVNgPFr
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation protocol:data tooling labels Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/data/filter-empty-operator.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-driven.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/external-datasources.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/field-types.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/validation-rules.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/troubleshooting.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/getting-started/quick-reference.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/backward-compatibility.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/types.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/concept.mdx (via FieldSchema (symbol, a top-level const))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-1.mdx (via FieldSchema (symbol, a top-level const))

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.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/data/filter-empty-operator.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 61 of 218 client-bound route-ledger rows — the other 157 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 157: 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; 102 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 — 168 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 3eeb81d19495b9e4064685fc9662936a834f017c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5ba46c89e5f4f1e7c2824cea3f57e8b87469d2e1 — the merge of head 3e61ed737b30c8d0dbea4cf7b0a0fa5b9e335926 into base 3eeb81d19495b9e4064685fc9662936a834f017c, 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 5ba46c89e5f4f1e7c2824cea3f57e8b87469d2e1 && git checkout 5ba46c89e5f4f1e7c2824cea3f57e8b87469d2e1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3eeb81d19495b9e4064685fc9662936a834f017c 3e61ed737b30c8d0dbea4cf7b0a0fa5b9e335926 && git checkout -B drift-repro 3eeb81d19495b9e4064685fc9662936a834f017c && git merge --no-ff 3e61ed737b30c8d0dbea4cf7b0a0fa5b9e335926

node scripts/docs-audit/affected-docs.mjs --json 3eeb81d19495b9e4064685fc9662936a834f017c

⚠️ 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 3eeb81d19495b9e4064685fc9662936a834f017c → 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:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract-review-claim: session_01WGufzfgvGexKn9vLHsy89K · head 3e61ed7 · 2026-11-03T03:51Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3e61ed737b30c8d0dbea4cf7b0a0fa5b9e335926
Local-runs: none
Scope: packages/spec/src/data/field.zod.ts, packages/spec/src/data/filter-empty-operator.ts + packages/spec/src/data/field-value.zod.ts (isMultiValueField, MULTI_OPTION_TYPES, MULTI_CAPABLE_TYPES), packages/spec/src/data/value-roundtrip-conformance.ts (m_empty), packages/core/src/utils/required-emptiness.ts, packages/objectql/src/engine.ts (cascade set_null member removal, the insert and update doors), packages/objectql/src/validation/record-validator.ts (normalizeBlankTypedValues), packages/core/src/utils/blank-storage-form.ts and the same two files on the consumer branch claude/project-thread-tfknlu at 55aa2656, docs/adr/0104 and docs/adr/0113

Inputs read: card #29652 body and all four comments (triage 6592466576, the two claims, os-dev-report 6594958096); ruling record 6591577985 on #29343; PR #29763 body, file list (6 files, +45/-20) and the net diff against the merge base 0b2b309c (origin/main is at 3eeb81d1, which does not touch these paths); the check-runs on the head. The rest of the diff (the changeset text and the three regenerated content/docs/references/** pages) is the lane seat's ACCEPT at TIER_DEFAULT; the cited code is read, never reviewed.

Check-runs on the head at review time: 23 success, 8 skipped, 31 still in progress, none failed. The landing still waits for the required set to conclude; this record judges the design, not the unfinished gates.

① Derived judgments

  • No accept set moves — right. The diff changes the required doc block, the multiple doc block and the multiple describe in field.zod.ts, and one docblock sentence in filter-empty-operator.ts. EMPTY_OPERATOR_ARMS, expandEmptyOperator, isEmptyFilterValue, the required describe and every default are byte-unchanged; no export is added, removed or renamed.
  • A published semantic contract changes — right, and declared. The multiple describe is what gen:docs renders into the multiple rows of references/data/field.mdx, references/data/object.mdx and references/system/migration.mdx; the regenerated hunks carry exactly the new describe. The promise an author reads about the stored empty value is reversed for optional columns: that is Metadata-protocol: semantic, not none.
  • The type list in the multiple doc block matches isMultiValueField — right. MULTI_OPTION_TYPES is multiselect, checkboxes, tags; MULTI_CAPABLE_TYPES is select, radio, lookup, user, file, image with multiple: true. The doc block omits radio, which the same describe refuses multiple: true at parse, so the list is the declarable set exactly.
  • The $empty table is unchanged — right. multi_value: { emptyString: false, emptyList: true } at filter-empty-operator.ts:77 is untouched, as the ruling requires.
  • No ADR is reversed — right. docs/adr/0104 and docs/adr/0113 pin neither [] nor null as the empty representation; the reversed sentence was the [Decision] Multi-value lookup after set_null member removal: does the emptied array read back as [] or null? #9447 maintainer ruling recorded in the doc block, and ruling A on objectql: a multi-value unique column (multiselect, or lookup with multiple: true) cleared to [] on two records refuses the second with 409 UNIQUE_VIOLATION #29343 amends it at the same level.
  • Skills-docs: none — right. git grep over skills/** at the head for the old spellings finds 0 hits.
  • check:generated current — consistent with the tree. The three mdx rows are the only generated artifacts the describe reaches.

② Semver level

  • Changeset .changeset/29652-multi-value-empty-null-docs.md: '@objectstack/spec': minor, with Clause-②: yes and Metadata-protocol: semantic at line start; the PR body carries the same two lines. The level table (scripts/check-changeset-no-major.mjs DECLARE block, AGENTS.md Post-Task Checklist step 3): semantic takes minor. The card's grading note ("a patch changeset is owed") predates the measurement and is the wrong row; Check Changeset is green on the head.
  • Clause-②: yes with no arm — right: no accept set widens or narrows, the PR is not BREAKING, and no ADR-0087 disposition marker is owed.

③ Boundary flags

  • open_questions in os-dev-report 6594958096: empty.
  • Deviations 1–4 (attribution-first body, no assignee/reviewer, a stopped local heredoc run with readings retaken on the final head, stray files outside the repo): closed; none touches the contract.
  • Out-of-scope finding, six hand-written content/docs/** pages still teaching "[], never null" (carrier objectql: a multi-value unique column (multiselect, or lookup with multiple: true) cleared to [] on two records refuses the second with 409 UNIQUE_VIOLATION #29343): agreed; they move with the write-door PR.
  • Out-of-scope findings on the required describe's lookup wording and value-roundtrip-conformance.ts:216 m_empty: agreed, not findings on this PR.
  • Escalation to objectql: a multi-value unique column (multiselect, or lookup with multiple: true) cleared to [] on two records refuses the second with 409 UNIQUE_VIOLATION #29343's lane (runtime prose and pins, not spec): the old sentence survives in packages/core/src/utils/required-emptiness.ts:32-36, packages/objectql/src/engine.ts:19474-19477 and :20249-20255, packages/objectql/src/validation/record-validator.test.ts:644-647, and in three pins the ruling's step 2 says the consumer PR moves — packages/objectql/src/engine-cascade-delete-multivalue-probe.test.ts:463,525,533, packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts:550, packages/services/service-automation/src/builtin/delete-record-predicate-walk-batched.integration.test.ts:216. The consumer branch claude/project-thread-tfknlu at 55aa2656 touches none of them; since the cascade writer calls this.update(...) (engine.ts:20266) and the update door runs normalizeBlankTypedValues (engine.ts:16968), those "writes [], never null" pins go red on the consumer's own PR. Outside this PR's surface; it does not change this verdict.

④ Design judgment

packages/spec/src/data/field.zod.ts — holds. The required doc block ("null and [] both fail it") is exactly what isEmptyForRequired computes on a multi-value def; against the consumer, blankAsNullColumns skips def.required === true columns (blank-storage-form.ts:148), so a [] on a required column is refused as [], never nulled first. The multiple doc block states ruling A's clauses (optional only, every isMultiValueField type, unique-covered or not, [] and '' store null, every writer, $empty unchanged, the required half stands) and cites the ruling beside #9447 with "readers never need a null branch" dropped. Against the consumer: blankAsNullColumns assigns 'emptyList' before the unique-coverage branch (:149-153), blankStorageForm nulls exactly '' or a zero-length array (:169), and both the insert door (engine.ts:15531) and the update door (:16968) run normalizeBlankTypedRow, the cascade's this.update included. Tried to break it: whitespace-only strings, explicit null, radio with multiple: true, rows stored before the rule holding [] — each consistent with the text. The describe carries no tracker id. No must-fix, no should-fix.

packages/spec/src/data/filter-empty-operator.ts — holds. The sentence at :90-92 now says a multi-value field "can hold [] (a cleared optional one stores null, but a row stored before that rule can still hold [])"; the arm-order rationale is unchanged and still true. No must-fix, no should-fix.

Implemented-by: claude/project-thread-ztk9p3
Reviewed-by: session_01WGufzfgvGexKn9vLHsy89K

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

维护者速读

席位意见:裁决 A 的四个条款逐条落在文本里,与 #29343 已建的写入口逐条对得上;同意落地。

席位核对 — ACCEPT · domain:spec#3 · head 3e61ed737b30c8d0dbea4cf7b0a0fa5b9e335926

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 04:16
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 3, 2026 04:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 4ab2e7e Nov 3, 2026
66 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-ztk9p3 branch November 3, 2026 04:38
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 protocol:data size/s tooling

Projects

None yet

1 participant