Skip to content

[Decision] Multi-value lookup after set_null member removal: does the emptied array read back as [] or null? #9447

Description

@os-zhuang

Split out of #9438 by the triage seat (the same shape as the #9364 → #9389 split): the interim repair on #9438 (escalate defaulted set_null to restrict for multiple: true, mirroring the pinned required-FK escalation) is queued and decides no semantics; this card carries the one question that blocks the real fix (#9438 option 1 — remove the member instead of refusing).

The question

When cascadeDeleteRelations removes the deleted record's id from a multiple: true lookup array and the array becomes empty, what does the field read back as — [] or null?

The difference is observable on the read path, and it decides what a required multi-value validator sees ([] is a present-but-empty value; null is an absent one). FieldSchema currently says nothing, no sibling declaration pins it, and no landed ruling covers it — which is exactly why #9438's implementer refused to guess it inside a P0 hotfix (#9362 / PR #9437).

Four-facet analysis

  • Platform long-term coherence: the answer should be one rule for every writer of multi-value lookups (cascade repair, form clears, API writes), not a cascade-only convention — whichever value is chosen, the ruling text should say it binds the field's empty representation generally, or explicitly scope it to cascade writes and say why.
  • Measured business pull: becomes live the moment fix(engine): probe a multiple:true reference field with a spelling its storage answers #9437 lands (the limb has never executed before it); the stock showcase carries the shape (showcase_field_zoo.f_lookups). Until option 1 lands, the interim restrict refusal (409) is what users see.
  • AI-agent error-resistance: [] keeps the field's type stable (an array field always reads as an array) — one less null-branch for generated code and formula/filter predicates to mishandle; null matches the single-value lookup's cleared state but makes the field's type a union in practice.
  • Startup scope discipline: either answer is one line in the repair plus a sentence in FieldSchema's doc; the cost is only in changing it later, so rule once, now.

Recommendation: [] — type-stable for every reader, consistent with "the set lost a member" rather than "the field was cleared", and it keeps required semantics honest (an emptied required set should fail validation loudly, which [] does and a vanished null may not, depending on the validator's absent-vs-empty treatment — the ruling should state which of those required means for multi-value lookups, since that is the same question wearing validation clothes).

On the ruling

The answer unblocks #9438 option 1 (member removal); the interim restrict escalation reverts in the same PR. Ruling text lands in FieldSchema's doc block (spec text surface — spec seat) and the engine repair consumes it.

Refs: #9438 (the defect and the interim) · #9362 / PR #9437 (what makes the limb reachable) · the pinned required-FK set_null → restrict escalation in the same block of packages/objectql/src/engine.ts.

Filed by the triage seat routine — session session_pm_triage_20260818T0104Z.

Activity

  1. added theissue type on Aug 18, 2026
  2. os-zhuang commented on Aug 18, 2026

    @os-zhuang
    ContributorAuthor

    Maintainer ruling recorded — maintainer, 2026-08-18, batch acceptance of the triage seat's decision-inbox recommendations, verbatim: 「同意」. Four-facet analysis delivered in-session; provenance: triage session session_pm_triage_20260818T0104Z.

    Ruled: the emptied multi-value lookup reads back as [], and in the same stroke: required on a multi-value lookup means non-empty array — an emptied required set fails validation loudly. The rule binds the field's empty representation generally (every writer: cascade repair, form clears, API writes), not as a cascade-only convention.

    Landing: the ruling sentence goes into FieldSchema's doc block (spec text surface — spec seat, clause-② semantic face, contract-review tier); #9438's option 1 (member removal, next = current.filter(...), empty ⇒ []) consumes it and reverts the interim restrict escalation in the same PR.

    Label needs-user-decision → pm:queue in the same stroke.


    Generated by Claude Code

  3. self-assigned this
    on Aug 18, 2026
  4. os-steve commented on Aug 18, 2026

    @os-steve
    Collaborator

    Claim: session session_01Fs18A2DdXLVN2h8PaaFBcP (the domain:spec execution seat, #6017), branch claude/issue-9447-multivalue-empty-representation. Clause-②: yes per the ruling comment (semantic face: defines the empty representation and what required means for multi-value lookups) — contract-review tier; if the Fable tier is quota-refused at launch, the measured quota exemption applies (→ opus, recorded here, compensating measured review by this seat before landing). Scope: the ruled sentences land in FieldSchema's doc block ([] general rule, every writer; required = non-empty array); the engine consumption belongs to #9438's PR, not this card. Generated-artifacts coupling declared: wave-8 siblings #9463/#9406/#9345 — landings serialize, later landers run os-regen-merge.


    Generated by Claude Code

  5. os-steve commented on Aug 18, 2026

    @os-steve
    Collaborator
    {
      "issue": 9447,
      "outcome": "delivered",
      "pr": 9493,
      "branch": "claude/issue-9447-multivalue-empty-representation",
      "head": "e48b04963",
      "before_after": "field.zod.ts `required` (was line 757, now 756-765): describe + new doc block gain 'On a multi-value lookup (multiple: true) required means NON-EMPTY array — an emptied required set fails validation loudly; [] does not satisfy it (#9447, maintainer ruling 2026-08-18)'. `multiple` (was line 775, now 783-793): describe + new doc block gain 'An emptied multi-value lookup reads back as [], never null — binds every writer (cascade repair, form clears, API writes), not just cascade repair (#9447, maintainer ruling 2026-08-18)'. content/docs/references/data/field.mdx regenerated via gen:docs. Full excerpts quoted in the PR body.",
      "current_behavior_measured": "Runtime probe (temp vitest vs validateRecord, deleted, never committed) at main=ad217b192: [] on a required+multiple:true lookup PASSES validation on both insert and update; null is rejected on both (required / required_cleared). Cause: record-validator.ts:171-173 isMissing covers undefined/null/blank-string only, read at :477 (insert) and :1011 (update). No pinned test asserts the opposite semantics — enforcement gap, not a ruling-vs-pin conflict, so the ruled doc landed and the divergence was filed.",
      "divergence_finding": 9476,
      "files": [
        "packages/spec/src/data/field.zod.ts",
        "content/docs/references/data/field.mdx",
        ".changeset/nine-camels-behave.md"
      ],
      "gates": [
        "check:generated (all 13 up to date, re-run at head e48b04963 after final commit)",
        "spec test 409 files / 10915 passed",
        "spec typecheck exit 0",
        "dispatch-gates union 21 families all PASS at e48b04963 (check-dev-prereqs green after full pnpm build; its first red was fresh-worktree build state, 52 missing dists, not the diff)",
        "check:nul-bytes PASS"
      ],
      "deviations": []
    }

    Generated by Claude Code


    Generated by Claude Code

  6. os-steve commented on Aug 18, 2026

    @os-steve
    Collaborator

    ACCEPT — review of record against PR #9493 (head e48b04963), full diff read (3 files).

    • Both ruled sentences landed where a reader will meet them: required's doc block + describe carry "non-empty array, [] does not satisfy it", and multiple's carry "reads back as [], never null — binds every writer", each citing the ruling date and cross-referencing the other. The reference page regenerated to match. Acceptance byte-identical; patch changeset.
    • The measured-divergence instruction was executed exactly right: a runtime probe (temporary, deleted, never committed — with null controls both directions) proved [] on a required+multiple: true lookup PASSES validation today (record-validator.ts:171-173 isMissing covers undefined/null/blank only, read at :477 insert and :1011 update). No pinned test asserts the opposite, so this is an enforcement gap versus the ruled contract, not a ruling-vs-pin conflict — the doc landed as ruled and the gap is on record as record-validator: [] satisfies required on a multiple: true lookup — diverges from the #9447 ruling (required means non-empty array) #9476 with the read sites. The cascadeDeleteRelations' set_null limb nulls the WHOLE multi-value array, dropping every other live reference #9438 lane (which consumes this ruling) and triage now have the enforcement question in queueable form.
    • Gates: 21 derived families green at head, spec 10915 tests, check:generated 13/13 re-run post-final-commit. No deviations.
    • Clause-② dispatch ran AT the contract-review tier — no label loop.

    Landing: wave-8 serial relay — #9489 (#9463) holds the current slot; this PR lands next (ready + auto-merge squash after #9489 merges and CI here is green; os-regen-merge if dirty), ahead of #9492 (awaiting contract-review clearance) and #9406 (in flight).


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions