Skip to content

fix(spec): stop offering a master_detail the deleteBehavior its schema refuses - #11769

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-11410-master-detail-form-options
Aug 24, 2026
Merged

os-warren merged 2 commits into
mainfrom
claude/issue-11410-master-detail-form-options

Conversation

@os-warren

@os-warren os-warren commented Aug 24, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #11410

The field designer offered "Set null" as a deleteBehavior for a master_detail
field — a combination #9689 (PR #11406, merged 9086761ed) turned into a named
parse-time rejection. The author picked it and learned only at publish, from a
422, that the choice was never legal. Declared-vs-enforced, one seam earlier than
the parse door.

Dispatch fence (triage comment 5389685394, quoted 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.

Honored: the DSL is unchanged. The shape used is field-level visibleWhen, the
predicate the metadata-admin renderer already evaluates for every other
type-conditional control in these two files.

Survey — which existing-vocabulary shape, and why

The card named three candidates. Both alternatives to the chosen shape were ruled
out by measurement, not by taste.

Candidate Verdict Evidence
Per-option visibleWhen Rejected Key exists (SelectOptionSchema, ADR-0068), but is unreachable from a metadata form — see below
Option-source hook Does not exist No option-source key in FormFieldBaseSchema; the only sources are inline options or the derived JSON Schema enum
Two selects, disjoint visibleWhen Chosen Uses the exact call the renderer already makes: evaluatePredicate(visibility, { data })

Per-option visibleWhen is the tighter-looking spelling and reads as existing
vocabulary. It is inert here in both directions:

  1. The metadata-admin renderer maps fieldSpec.options straight to select items
    and never consults opt.visibleWhen. It calls evaluatePredicate only for
    section / field / repeater-row visibility. Writing a per-option predicate
    would ship an ADR-0049 declared-but-unenforced key.
  2. On the runtime surface that does honor per-option visibility
    (resolveCascadingOptions to evalFieldPredicate), the scope binds record,
    previous and extra — and never data. A metadata form spells its
    predicates data.*, so a data.-rooted per-option predicate is an unbound
    identifier, and visibility's fallback is TRUE: the option is kept.

Either way the author would still be offered "Set null" — a fix that silently
does nothing. Zero *.form.ts in the repo uses per-option visibleWhen today.
Filed as #11793 so the next author who reaches for the key does not have to
re-measure it.

The card's NEEDS-CHECKING half is answered: yes, it shares the defect

The card flagged whether field.form.ts's option source narrows per type. It
does not, and it fails through a different mechanism than its sibling:

  • object.form.ts declared an inline three-value options list behind one
    visibleWhen naming both types.
  • field.form.ts declared no options at all. That is not a narrower offer
    but the renderer's derived source: with no inline list it falls through to the
    JSON Schema enum. Measured:
    {"default":"set_null","type":"string","enum":["set_null","cascade","restrict"]}
    — all three values, and set_null is additionally the advertised default.

A Zod enum has no per-type narrowing to give, so master_detail can only be
served by an explicit list; omitting one re-offers the refused value by
construction. This is why the repair is not one edit applied twice.

What changed

Both forms declare the control twice, with disjoint visibleWhen:

  • lookup keeps all three outcomes. object.form.ts keeps its three-value list
    byte-identically; field.form.ts's lookup branch keeps deriving from the
    enum, so its labels and their translation are untouched.
  • master_detail is offered cascade and restrict only.

The two branches are mutually exclusive, so no author ever sees both.

helpText is deliberately identical on both declarations in each file: the i18n
bundle keys form fields by field PATH, so the two declarations share one
translation key and divergent text would collide silently. This is not a
theory — check:i18n reports all 9 packages' bundles in sync with no
regeneration needed, which is the reading that would have moved had the two
declarations disagreed.

Reverse verification

Test written first, run against unfixed source. Both readings are from the
same test file, packages/spec/src/data/form-delete-behavior-options.test.ts.

RED — unfixed source (Test Files 1 failed | 420 passed (421),
Tests 3 failed | 11229 passed (11232)) — it failed by finding the offered
option, not by an unrelated error:

FAIL objectForm > does not offer set_null to a master_detail author
AssertionError: expected [ 'set_null', 'cascade', 'restrict' ] to not include 'set_null'

FAIL fieldForm > does not offer set_null to a master_detail author
AssertionError: $.sections[1].fields[10] declares no inline options, so it falls
through to the derived enum (which offers set_null). The master_detail branch
must declare an explicit list.: expected undefined to be defined

FAIL the two forms agree on what a master_detail may be offered
AssertionError: expected [ 'set_null', 'cascade', 'restrict' ] to deeply equal
[ the derived-source sentinel ]

(The third message's sentinel is spelled with angle brackets in the test source;
it is written out here because the GitHub body sanitizer strips short
angle-bracket fragments, which would have made it read as an empty string.)

The rest of the spec suite was green in that same run (11229 passed), so the
three failures are the new assertions alone.

GREEN — fixed source: Test Files 1 passed (1) · Tests 14 passed (14).

What the test pins beyond the absent value

exactly one visible control per type is asserted for both lookup and
master_detail. Two declarations of one key have two failure modes a bare
"set_null is absent" assertion would miss: predicates that overlap (two
selects writing one key) and predicates that leave a gap (the control
vanishes for that type). The test's own predicate evaluator throws on any
spelling it does not recognise rather than assuming visible — fail-closed on
drift, deliberately the opposite of the renderer's fail-open default.

The second commit: a consumer census, not a weakened assertion

packages/lint's #7010 corpus block pins the size of the shipped
METADATA_FORM_REGISTRY predicate population as equalities (deliberately,
so a subset can never satisfy the anti-vacuity guard). Adding one predicate to
each form moves three counts: total predicates 46 to 48, ==/!= literal
comparisons 45 to 47, and the object form's data.type-rooted sites 16 to 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. The
reverse-verification prose now separates two numbers it had conflated: #6254's
own measurement stays 16 (history), while the restore count tracks the corpus.

Clause-②

Clause-②: no on the declaration limb (spec parse contract untouched — this
narrows what the Studio form offers); the path limb fires, so the
contract-review chain runs before enqueue; the PR stays draft.

Verification

All readings taken at head 172b19c28, after the final commit. Verdicts are
quoted from each gate's own output line, never from a piped $?.

Check Reading
pnpm --filter @objectstack/spec test (full) Test Files 421 passed (421) · Tests 11232 passed (11232)
pnpm --filter @objectstack/spec typecheck pass — incl. check:test-typecheck: OK (test layer compiles)
pnpm --filter @objectstack/lint test (consumer) Test Files 81 passed (81) · Tests 2280 passed (2280)
pnpm --filter @objectstack/lint typecheck pass
pnpm --filter @objectstack/spec 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, no undeclared authoring keys)
spec liveness family check:empty-state, check:liveness, check:strictness-ledger, check:variant-docs — all pass
check:cross-package-test-inputs pass (both the pnpm and scripts/ invocations)
changeset family check:changeset-gate-self-tests, check-changeset-no-major, check-empty-changeset, check-adr-0087-registration, check:objectui-changeset, release-rehearsal-clone --self-test — all pass
convention-triggered (new test file) check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:type-check-coverage — all pass
others named by derivation check:published-files, check:slot-lookup, check:spec-parsed-alias, check:test-source-alias, check:type-source-resolution, check:merge-driver, check-plugin-teardown-shape, check-affected-docs, check-nul-bytes — all pass

Gate list derived, not recalled:

dispatch-gates: gate list derived from the tree of 'objectstack-ai/objectstack' at commit 172b19c28
dispatch-gates: change set derived from git — 5 path(s) vs merge base 2a6122bd9 of 'origin/main' and HEAD

Two items deliberately not claimed as green, so the distinction stays honest:

  • check-dev-prereqs — red locally for one reason it names itself: "The
    workspace is not built". An environment precondition of this worktree, not a
    finding about this diff; CI builds first.
  • check:type-check-debt --re-measure — not measured here. 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 own check:test-typecheck confirms the new test file
    compiles under tsconfig.test.json.

Landing is the PM's — this PR stays draft and carries no auto-merge.


Generated by Claude Code

…a refuses

#9689 made `deleteBehavior: 'set_null'` on a `master_detail` a named
parse-time rejection, but the two shipped metadata forms kept offering
"Set null" for that type — the author picked it and learned only at
publish, from a 422, that the choice was never legal.

The two forms reached that defect through different option sources.
`object.form.ts` carried an inline three-value list behind one
`visibleWhen` naming both types. `field.form.ts` declared no `options`
at all, which is not a narrower offer but the renderer's derived
source: with no inline list it falls through to the JSON Schema enum
(`set_null`, `cascade`, `restrict`, plus `default: 'set_null'`). A Zod
enum has no per-type narrowing to give, so `master_detail` can only be
served by an explicit list.

Both now declare the control twice with disjoint `visibleWhen`.
`lookup` is untouched — `object.form.ts` keeps its three-value list and
`field.form.ts` keeps deriving from the enum, so its labels and their
translation are unchanged. `master_detail` is offered `cascade` and
`restrict` only.

Per-option `visibleWhen` (`SelectOptionSchema`, ADR-0068) was measured
and rejected: the metadata-admin renderer maps `fieldSpec.options`
straight to select items and never consults it, so it would ship an
ADR-0049 declared-but-unenforced key; and the runtime evaluator that
does honor it binds `record`, never `data`, so a `data.`-rooted
predicate is unbound there and visibility fails open, keeping the
option. Either way the author would still be offered "Set null".

The pinning test requires exactly one visible control per type, because
two declarations of one key can fail by overlapping as well as by
leaving a gap.

Fixes #11410

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy
@github-actions

github-actions Bot commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

21 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 8b41bcd85160ac1c475d26b2968d74f84954bbfa.

⛔ 5 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)

Coarse fallback — 126 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 8b41bcd85160ac1c475d26b2968d74f84954bbfa → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8b41bcd85160ac1c475d26b2968d74f84954bbfa

⚠️ 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 8b41bcd85160ac1c475d26b2968d74f84954bbfa → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Aug 24, 2026
…vior split

The `#7010 corpus` block pins the size of the shipped
`METADATA_FORM_REGISTRY` predicate population as EQUALITIES, deliberately
rather than floors, so a subset can never satisfy the anti-vacuity guard.
Splitting `deleteBehavior` into two declarations with disjoint
`visibleWhen` adds one predicate to each of the two forms, so three
counts move: total predicates 46 to 48, `==`/`!=` literal comparisons 45
to 47, and the object form's `data.type`-rooted sites 16 to 17.

Counts only — no assertion is weakened and no rule changed. The
`reports NOTHING over the shipped forms` case was already green
throughout, which is the independent evidence that the two new
predicates resolve against the real schemas rather than merely being
counted.

The reverse-verification prose now separates the two numbers it had
conflated: #6254's own measurement stays 16 (that is history), while the
restore count tracks the corpus and is 17 today.

Part of #11410

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy
@os-warren
os-warren marked this pull request as ready for review August 24, 2026 21:39
@os-warren
os-warren added this pull request to the merge queue Aug 24, 2026
Merged via the queue into main with commit 8b13cc8 Aug 24, 2026
45 checks passed
@os-warren
os-warren deleted the claude/issue-11410-master-detail-form-options branch August 24, 2026 22:02
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants