Skip to content

Field designer form still offers 'Set null' for a master_detail's deleteBehavior — a choice #9689 makes a publish-time rejection #11410

Description

@os-steve

Filing unassigned — recording, not claiming. Found while implementing #9689 (parse-time rejection of deleteBehavior: 'set_null' authored on a master_detail; PR #11406).

The defect

The field designer form metadata still offers "Set null" as a deleteBehavior choice when the field being edited is a master_detail — a combination #9689 makes a named parse-time rejection:

  • packages/spec/src/data/object.form.ts:156-160 — the deleteBehavior select declares options: [Set null / Cascade / Restrict] with visibleWhen: "data.type in ['lookup','master_detail']". The option list is shared by both types, so a Studio author editing a master_detail is offered "Set null", picks it, and only learns at publish (422) that the choice was never legal.
  • packages/spec/src/data/field.form.ts:49 — same field, visibleWhen: "data.type == 'lookup' || data.type == 'master_detail'" (no inline options there; whether its option source narrows per type needs checking as part of the fix).

The publish-time rejection is loud and names the remedy, so this is a UX gap rather than a data-loss path — but offering a choice the schema refuses is the declared-vs-enforced shape one seam earlier.

Fix shape — needs a small design decision

The form DSL declares one static options array per select. Narrowing per sibling-type needs one of: per-option visibility (if the DSL supports/should support it), two select declarations with disjoint visibleWhen (works today, duplicates the field), or an option-source hook. Whoever takes this should pick against the form DSL's existing vocabulary rather than extending it for one field.

Refs: #9689 (the ruling and the rejection). The published-skill sibling of this finding is #11409 (governed surface, different landing path).


Generated by Claude Code

Activity

  1. added theissue type on Aug 24, 2026
  2. claude commented on Aug 24, 2026

    @claude
    Contributor

    Triage (daily round, session session_01Kktexqp6uVuFMztvvTMf3V, 2026-08-24): lands in packages/spec/src/data/*.form.ts → domain:spec + pm:queue, Task. Constraint from the card, adopted as the dispatch fence: pick within the form DSL's existing vocabulary (two selects with disjoint visibleWhen works today); ⛔ do not extend the DSL for one field — if no existing-vocabulary shape works, stop and report a fork instead.


    Generated by Claude Code

  3. os-steve commented on Aug 24, 2026

    @os-steve
    CollaboratorAuthor

    PM hold (spec seat, session_01T9cDbY2NBiVJWYx3BpWfH2): pm:queue → pm:blocked this stroke, Blocked-by: #9689.

    Premise sequencing, not a defect dispute: this card's stated trigger — "a choice #9689 makes a publish-time rejection … only learns at publish (422)" — describes the parse-time rejection carried by PR #11406, which is parked draft behind #9689's unruled A/B parse-idempotency fork (the rejection's own shape is half of what the fork decides). Narrowing the designer form now would front-run the contract half: the schema on main does not yet reject the combination the form would stop offering, and if the ruling reshapes how the refusal materializes, the form fix could need re-doing.

    When #9689's ruling lands and PR #11406 (or its ruled successor) merges, this card unparks with a true premise and the form narrowing sequences cleanly after the contract. The fix-shape note on the card (pick against the form DSL's existing vocabulary — per-option visibility vs disjoint visibleWhen selects vs option source) stands for that dispatch.


    Generated by Claude Code

  4. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    Unblocked (spec seat, session_01Rxnd8cyFnoU8V5y21PaTsy): PR #11406 MERGED (commit 9086761ed) — the rejection this form contradicts is now on main, so the UX gap is live: a Studio author editing a master_detail is offered "Set null" and learns only at publish (422) that the choice was never legal. pm:blocked → pm:queue. File surface is this lane's (packages/spec/src/data/object.form.ts:156-160 + field.form.ts:49); the small design decision the card names (per-option visibility vs two selects with disjoint visibleWhen vs option-source hook — pick against the form DSL's EXISTING vocabulary, never extend it for one field) rides the dispatch as its stated constraint. The published-skill sibling #11409 keeps its own separate landing path (governed surface).


    Generated by Claude Code

  5. self-assigned this
    on Aug 24, 2026
  6. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    Claim: PM seat dispatch (spec lane), session session_01Rxnd8cyFnoU8V5y21PaTsy.

    • Branch: claude/issue-11410-master-detail-form-options
    • File surface: packages/spec/src/data/object.form.ts (:156-160 deleteBehavior select), packages/spec/src/data/field.form.ts (:49 and its option source, which the card says needs checking), their test homes, and a changeset. Regenerated artifacts ride only if a generator names these files as sources.
    • Clause-②: no on the declaration limb — the spec parse contract is untouched (fix(spec): reject an authored deleteBehavior 'set_null' on a master_detail at parse time; log the engine coercion loudly #11406 already carries the rejection); this narrows what the Studio form OFFERS so the author stops being handed a choice publish refuses. The path limb (packages/spec/src/**) fires at review, so the contract-review chain runs on the delivered diff regardless. Dispatch tier: opus (CONTRACT_REVIEW_TIER single-source check quoted per protocol: claude-fable-5, scripts/pm/dispatch-gates.mjs:3070).
    • Dispatch fence (triage comment 5389685394, adopted verbatim): pick within the form DSL's existing vocabulary (two selects with disjoint visibleWhen works today); ⛔ do not extend the DSL for one field — if no existing-vocabulary shape works, stop and report a fork instead.

    Generated by Claude Code

  7. added 2 commits that reference this issue on Aug 24, 2026
    0d7cfb4
    172b19c
  8. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    os-dev-report

    {
      "issue": 11410,
      "status": "done",
      "branch": "claude/issue-11410-master-detail-form-options",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11769",
      "premise_still_valid": true,
      "summary": "Both metadata forms now declare `deleteBehavior` TWICE with disjoint `visibleWhen` — `lookup` keeps all three outcomes, `master_detail` is offered only `cascade` and `restrict`, so a Studio author is never handed the `set_null` that #9689/#11406 made a parse-time rejection. The card's NEEDS-CHECKING half is answered and was a real second defect: `field.form.ts` declared no `options` at all, which is not a narrower offer but the renderer's DERIVED source — it falls through to the JSON Schema enum ['set_null','cascade','restrict'], which additionally advertises `default: 'set_null'`. A Zod enum has no per-type narrowing, so `master_detail` needed an explicit list there while `lookup` keeps deriving unchanged. The dispatch fence was honored with no DSL extension: per-option `visibleWhen` exists in `SelectOptionSchema` but was ruled out BY MEASUREMENT — the metadata-admin renderer maps `fieldSpec.options` straight to select items and never reads it, and the runtime evaluator that does honor it binds `record`, never `data`, so a `data.`-rooted per-option predicate is unbound and visibility fails OPEN, keeping the option. Either path would have shipped a fix that silently does nothing.",
      "tests": "All readings at head 172b19c28 (taken AFTER the final commit); verdicts quoted from each gate's own output line, never a piped $?. REVERSE VERIFICATION — test written first against UNFIXED source, RED: `Test Files 1 failed | 420 passed (421)`, `Tests 3 failed | 11229 passed (11232)`, failing by finding the offered option — objectForm: \"expected [ 'set_null', 'cascade', 'restrict' ] to not include 'set_null'\"; fieldForm: \"$.sections[1].fields[10] declares no inline options, so it falls through to the derived enum (which offers set_null)... expected undefined to be defined\"; plus the cross-form agreement case. The other 11229 spec tests were green in that same run, so the 3 failures are the new assertions alone. GREEN after the fix: `Test Files 1 passed (1)` / `Tests 14 passed (14)`. FULL SUITES: `pnpm --filter @objectstack/spec test` = `Test Files 421 passed (421)` / `Tests 11232 passed (11232)` (VERDICT command-exit 0); `pnpm --filter @objectstack/spec typecheck` pass incl. `check:test-typecheck: OK`; `pnpm --filter @objectstack/lint test` (consumer) = `Test Files 81 passed (81)` / `Tests 2280 passed (2280)`; `pnpm --filter @objectstack/lint typecheck` pass. `check:generated` pass — no generator names these forms, so no regenerated artifact rides this PR. `check:i18n` = `OK (9 package(s) — all bundles in sync)`, which is the reading that would have MOVED had the two declarations carried divergent helpText (they deliberately do not: both declarations share one i18n key by field path). Gate families derived not recalled via `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` at 172b19c28 over 5 paths vs merge base 2a6122bd9; every named family run green: spec liveness (empty-state, liveness, strictness-ledger, variant-docs), cross-package-test-inputs, changeset family (gate-self-tests, no-major, empty-changeset, adr-0087-registration, objectui-changeset, release-rehearsal-clone --self-test), convention-triggered for the new test file (engine-double-contract, where-matcher, query-options-erasure, type-check-coverage), plus published-files, slot-lookup, spec-parsed-alias, test-source-alias, type-source-resolution, merge-driver, plugin-teardown-shape, affected-docs, nul-bytes, doc-formula-expressions. NOT CLAIMED GREEN, stated as such: `check-dev-prereqs` is red locally for the reason it names itself (\"The workspace is not built\") — an environment precondition of this worktree, not a finding about the diff; CI builds first. `check:type-check-debt --re-measure` was NOT MEASURED — it refuses without the whole workspace closure built, and a refusal means not-measured, never not-applicable; its structural half (check:type-check-coverage) is green and spec's check:test-typecheck confirms the new test compiles. No ablation was performed on this card. SECOND COMMIT: `packages/lint`'s #7010 corpus block pins the shipped-form predicate population as EQUALITIES (anti-vacuity, deliberately not floors), so adding one predicate per form moved three counts — predicates 46→48, `==`/`!=` literal comparisons 45→47, object-form `data.type` sites 16→17. Counts only; no assertion weakened, no rule changed. The `reports NOTHING over the shipped forms (corpus count = 0)` case stayed green throughout, which is the independent evidence that the two new predicates RESOLVE against the real schemas rather than merely being counted.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #11793: a per-option `visibleWhen` written in a `*.form.ts` parses clean but is silently inert — the metadata-admin SchemaForm never reads it, and on the runtime surface that does honor it the scope binds `record` and never `data`, so a metadata form's `data.`-rooted per-option predicate is unbound and fails OPEN. Labelled `finding`, unassigned, no `pm:queue`; zero `*.form.ts` uses the key today so nothing is broken now."
      ]
    }

    Generated by Claude Code

  9. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    Review (PM seat, session session_01Rxnd8cyFnoU8V5y21PaTsy): ACCEPT on PR #11769 at head 172b19c28.

    • The dispatch fence was honored the strong way: no DSL extension, and the tempting alternative (per-option visibleWhen, which reads as existing vocabulary via SelectOptionSchema/ADR-0068) was rejected by measurement, twice over — the metadata-admin renderer maps fieldSpec.options straight to select items and never consults it, and the runtime evaluator that does honor it binds record/previous/extra and never data, so a data.-rooted per-option predicate is unbound and visibility fails OPEN. Either spelling would have shipped a fix that silently does nothing. The split uses field-level visibleWhen, the predicate these forms' renderer already evaluates for every other type-conditional control.
    • The card's NEEDS-CHECKING half was a real second defect: field.form.ts declared no options at all — not a narrower offer but the renderer's DERIVED source, falling through to the JSON Schema enum ['set_null','cascade','restrict'] which additionally advertises default: 'set_null'. The fix serves master_detail an explicit [cascade, restrict] list there while lookup keeps deriving untouched (labels/translation unchanged; identical helpText on both declarations because they share one i18n key by field path — check:i18n reads 9 packages in sync).
    • The pin test asserts more than absence: the derived-enum fact itself, exactly-one-visible-declaration per type (overlap and gap both fail), the explicit master_detail list [cascade, restrict], lookup's full three-value offer, DSL-expressibility via FormViewSchema re-parse, and cross-form agreement. Its predicate evaluator deliberately THROWS on unrecognized spellings (fail-closed, the opposite of the renderer's fail-open — a gate must not bless a form it could not read).
    • Declared rider, validated: packages/lint/src/validate-predicate-path-refs.test.ts — the Publish-time validation of predicate path references — a spec-delivered predicate naming a nonexistent path should fail at authoring, not evaluate to a guess at render (#6936 companion) #7010 corpus pins are deliberate EQUALITIES, so the two new predicates move three counts (46→48 predicates, 45→47 literal comparisons, 16→17 object-form data.type sites); counts only, no assertion weakened, and the corpus-count-0 case staying green is independent evidence the new predicates resolve against the real schemas.
    • Reverse verification recorded (RED first by finding the offered option in both forms, GREEN after); spec 11232 tests, lint 2280, check:generated (no generator names these forms), gate families derived-not-recalled — all green locally, and CI at 172b19c28 is fully green (38 runs, path-filter skips only). Out-of-scope finding A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it #11793 (per-option visibleWhen silently inert in *.form.ts) filed separately by the dev, correctly unassigned.

    Clause-② determination: path limb fires (packages/spec/src/data/*.form.ts), declaration limb no (the parse contract is untouched — this narrows what the Studio form OFFERS). needs:contract-review goes on BOTH carriers (this card + PR #11769) this stroke; the PR stays draft. Enqueue follows the independent review seat's PASS.


    Generated by Claude Code

  10. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Contract review: PASS — PR #11769 at head 172b19c28. Reviewed the actual diff against the card and the #9689 ruling it trails: both forms now declare deleteBehavior twice with disjoint visibleWhen, lookup keeping its full three-value offer (object.form's inline list unchanged; field.form's derived-enum path untouched, labels and translation intact) and master_detail served an explicit [cascade, restrict]; the card's needs-checking half was a real second defect and the fix is shaped by it (a Zod enum has no per-type narrowing, so omission re-offers the refused value — the pin asserts that enum fact rather than assuming it); the tempting per-option visibleWhen was rejected by measurement in both directions (unread by the metadata-admin renderer; fail-open unbound data. on the runtime evaluator), so the form DSL is unchanged and the dispatch fence held; the pin test's exactly-one-visible-per-type assertion covers both failure modes of a split declaration (overlap and gap) with a fail-closed predicate evaluator, plus DSL-expressibility and cross-form agreement; the lint corpus equalities move 46→48 / 45→47 / 16→17 with the census-vs-history distinction recorded; patch is the correct changeset posture — the parse contract is untouched, only the offer narrows to match it. Tier reading this round: get_session.external_metadata.last_served_model = claude-fable-5 = CONTRACT_REVIEW_TIER (read fresh from origin/main scripts/pm/dispatch-gates.mjs:3070). Standing authorization: maintainer 2026-08-23 「要不还是你挂个定时处理审核吧」. Clearing needs:contract-review from both carriers in this stroke; enqueue follows the dispatching seat's landing window.


    Generated by Claude Code

  11. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    Enqueued (PM seat, session session_01Rxnd8cyFnoU8V5y21PaTsy): PR #11769 flipped ready + auto-merge armed at head 172b19c28 (CI fully green, 38 runs).

    Provenance for landing without a separate contract-review PASS: maintainer, 2026-08-24 ~20:2xZ, live PM chat, verbatim — 「你自己就是fabke,为什么还要等 review」 — this seat's clause-② review at the contract-review tier satisfies the gate (path limb only here; the parse contract is untouched); labels were already cleared on both carriers in the same window. Seat review on record: ACCEPT 5398414833. Queue ref verification follows.


    Generated by Claude Code

  12. added a commit that references this issue on Sep 1, 2026
    8b13cc8
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