Repository navigation
fix(spec): stop offering a master_detail the deleteBehavior its schema refuses - #11769
Conversation
…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
📓 Docs Drift CheckThis PR changes 1 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…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
Fixes #11410
The field designer offered "Set null" as a
deleteBehaviorfor amaster_detailfield — a combination #9689 (PR #11406, merged
9086761ed) turned into a namedparse-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)
Honored: the DSL is unchanged. The shape used is field-level
visibleWhen, thepredicate 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.
visibleWhenSelectOptionSchema, ADR-0068), but is unreachable from a metadata form — see belowFormFieldBaseSchema; the only sources are inlineoptionsor the derived JSON SchemaenumvisibleWhenevaluatePredicate(visibility, { data })Per-option
visibleWhenis the tighter-looking spelling and reads as existingvocabulary. It is inert here in both directions:
fieldSpec.optionsstraight to select itemsand never consults
opt.visibleWhen. It callsevaluatePredicateonly forsection / field / repeater-row visibility. Writing a per-option predicate
would ship an ADR-0049 declared-but-unenforced key.
(
resolveCascadingOptionstoevalFieldPredicate), the scope bindsrecord,previousandextra— and neverdata. A metadata form spells itspredicates
data.*, so adata.-rooted per-option predicate is an unboundidentifier, 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.tsin the repo uses per-optionvisibleWhentoday.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. Itdoes not, and it fails through a different mechanism than its sibling:
object.form.tsdeclared an inline three-valueoptionslist behind onevisibleWhennaming both types.field.form.tsdeclared nooptionsat all. That is not a narrower offerbut 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_nullis additionally the advertised default.A Zod enum has no per-type narrowing to give, so
master_detailcan only beserved 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:lookupkeeps all three outcomes.object.form.tskeeps its three-value listbyte-identically;
field.form.ts'slookupbranch keeps deriving from theenum, so its labels and their translation are untouched.
master_detailis offeredcascadeandrestrictonly.The two branches are mutually exclusive, so no author ever sees both.
helpTextis deliberately identical on both declarations in each file: the i18nbundle 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:i18nreports all 9 packages' bundles in sync with noregeneration 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 offeredoption, not by an unrelated error:
(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 typeis asserted for bothlookupandmaster_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 corpusblock pins the size of the shippedMETADATA_FORM_REGISTRYpredicate 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,
==/!=literalcomparisons 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 greenthroughout, 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 arequoted from each gate's own output line, never from a piped
$?.pnpm --filter @objectstack/spec test(full)Test Files 421 passed (421)·Tests 11232 passed (11232)pnpm --filter @objectstack/spec typecheckcheck: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 typecheckpnpm --filter @objectstack/spec check:generatedcheck:i18nOK (9 package(s) — all bundles in sync, no undeclared authoring keys)check:empty-state,check:liveness,check:strictness-ledger,check:variant-docs— all passcheck:cross-package-test-inputsscripts/invocations)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 passcheck:engine-double-contract,check:where-matcher,check:query-options-erasure,check:type-check-coverage— all passcheck: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 passGate list derived, not recalled:
Two items deliberately not claimed as green, so the distinction stays honest:
check-dev-prereqs— red locally for one reason it names itself: "Theworkspace 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 refuseswithout the whole workspace closure built, and a refusal means not measured,
never not applicable. Its structural half (
check:type-check-coverage) isgreen, and spec's own
check:test-typecheckconfirms the new test filecompiles under
tsconfig.test.json.Landing is the PM's — this PR stays draft and carries no auto-merge.
Generated by Claude Code