Repository navigation
fix(spec): reject an authored deleteBehavior 'set_null' on a master_detail at parse time; log the engine coercion loudly - #11406
Conversation
…l at parse time
Relocate the property-level .default('set_null') off deleteBehavior into a
post-check .overwrite() (the currency-precision Option A shape) so the
field-level superRefine can tell authored from defaulted; an authored
set_null on a master_detail is now a named parse-time rejection, while parse
output stays byte-identical for every accepted input (shape-position
re-materialization, measured 13/13 on a before/after battery). Ship the
SemanticMigration entry (no automatic conversion - only the author knows
whether they meant restrict or cascade), and log loudly at the engine's
coercion site when the combination still arrives via a raw registration or a
pre-tightening stored row (error, falling back to warn). Engine behavior is
unchanged; the existing cascade pin stays true. Docs rows that described the
silent acceptance now state the rejection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2
The coercion-site log fires whenever a parent delete computes a misdeclared child field's behavior - with or without child rows - so the negative control must not register the misdeclared object at all. Message wording adjusted to not assert rows exist (CASCADES its referencing child rows). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2
📓 Docs Drift CheckThis PR changes 2 package(s): 26 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 — 127 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 4b2a2072c1d748aeef4614e0bc3c6081999dad7f && git checkout 4b2a2072c1d748aeef4614e0bc3c6081999dad7f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7181101a536b56dfa3004e91eb9811d1a1c5e34c 2bed67490cdf8d6ad363daa404d08beb698f7ce3 && git checkout -B drift-repro 7181101a536b56dfa3004e91eb9811d1a1c5e34c && git merge --no-ff 2bed67490cdf8d6ad363daa404d08beb698f7ce3
node scripts/docs-audit/affected-docs.mjs --json 7181101a536b56dfa3004e91eb9811d1a1c5e34c
|
…ter-detail-set-null-r2
… as authored — bare master_detail parse output omits deleteBehavior Maintainer ruling 2026-08-24 (A — idempotent materialization) on #9689's R2 increment: the .overwrite() baked set_null onto bare master_detail, the superRefine refuses the authored spelling, and the two are indistinguishable by design — so ObjectSchema.create() output rejected itself on defineStack's re-parse and every app build with a bare master_detail failed (showcase: 4 fields, reproduced red pre-fix, green post-fix). One conditional in the .overwrite(); every other type keeps byte-identity (13/14 battery entries byte-identical, the flip is the omitted key only); parse(parse(x)) pinned idempotent at both FieldSchema and the create()->defineStack chain; the shape-position pin re-targeted to lookup. Engine behavior unchanged (absent was already cascade on this type). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy
…eep the TEST_DEBT ratchet at 354 The R2 helper's 1-arg call added a TS2554 to the frozen debt pile (354 -> 355); check:type-check-debt --re-measure is shrink-only and the author remedy is fixing the new error. House 2-arg spelling (batch-row-authoring-feedback.test.ts precedent); file re-run green 16/16, re-measure back to 'none above its recorded number'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy
The published objectstack-data skill taught `deleteBehavior: 'set_null'` on a `master_detail` field in four places. That was already wrong before #9689 — the engine resolved every non-`restrict` value on this type to `cascade`, so the declaration promised keep-the-children and delivered delete-the-children — and since #9689 landed (PR #11406) it is a named parse-time rejection: an AI author following the skill produced metadata that fails publish with a 422. Teaches the per-type vocabulary instead: `master_detail` admits `cascade` / `restrict`; `set_null` belongs on `lookup`, where children survive the parent. Correction, not expansion — net line delta is exactly 0 in both files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ
…master_detail The `relationship/delete-behavior` suggestion in `lintDataModel` told an author an undeclared `master_detail.deleteBehavior` could be `cascade`, `restrict`, or `set_null`. Since #9689 (PR #11406, maintainer ruling 2026-08-19), an authored `deleteBehavior: 'set_null'` on a `master_detail` field is a named parse-time rejection, so following the suggestion literally walked an author into a publish-time error. The message now enumerates only the two values FieldSchema actually accepts on a master_detail (cascade/restrict — matching object.form.ts's master_detail deleteBehavior options), and keeps the outcome-naming courtesy of the parse-time rejection message: it still names set_null to say plainly it is not honored on this type, and points to `lookup` for the case where children must survive the parent. The `fix` payload was already correct and is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
…ly (objectstack-ai#11869) The published objectstack-data skill taught `deleteBehavior: 'set_null'` on a `master_detail` field in four places. That was already wrong before objectstack-ai#9689 — the engine resolved every non-`restrict` value on this type to `cascade`, so the declaration promised keep-the-children and delivered delete-the-children — and since objectstack-ai#9689 landed (PR objectstack-ai#11406) it is a named parse-time rejection: an AI author following the skill produced metadata that fails publish with a 422. Teaches the per-type vocabulary instead: `master_detail` admits `cascade` / `restrict`; `set_null` belongs on `lookup`, where children survive the parent. Correction, not expansion — net line delta is exactly 0 in both files. Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ Co-authored-by: Claude <noreply@anthropic.com>
…master_detail (objectstack-ai#13668) The `relationship/delete-behavior` suggestion in `lintDataModel` told an author an undeclared `master_detail.deleteBehavior` could be `cascade`, `restrict`, or `set_null`. Since objectstack-ai#9689 (PR objectstack-ai#11406, maintainer ruling 2026-08-19), an authored `deleteBehavior: 'set_null'` on a `master_detail` field is a named parse-time rejection, so following the suggestion literally walked an author into a publish-time error. The message now enumerates only the two values FieldSchema actually accepts on a master_detail (cascade/restrict — matching object.form.ts's master_detail deleteBehavior options), and keeps the outcome-naming courtesy of the parse-time rejection message: it still names set_null to say plainly it is not honored on this type, and points to `lookup` for the case where children must survive the parent. The `fix` payload was already correct and is unchanged. Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC Co-authored-by: Claude <noreply@anthropic.com>
Fixes #9689
Implements two maintainer rulings, both recorded on the card:
precisionthat contradicts the currency's ISO 4217 digits? #7918 Option A relocation, exactly as spiked), Q2 = C (no automatic conversion —SemanticMigrationentry only), Q3 = B (spec rejection plus a loud engine log at the coercion site), Q4 = B (maintainer authors the ADR — drafted below,docs/adr/**untouched)..overwrite()no longer materializesset_nullonmaster_detail; every other type keeps byte-identity; the superRefine rejection stays verbatim; artifacts stop carrying a value the schema itself refuses; parse becomes idempotent on the mainlinecreate()→defineStackpath. (The R2 increment's byte-identical materialization was measured self-rejecting on that path — every failing CI job died on@objectstack/example-showcase#build, whose 4 baremaster_detailfields were bakedset_nullby parse Add metamodel interfaces for ObjectQL/ObjectUI contract #1 and rejected by parse ✨ Set up Copilot instructions #2. Diagnosis: comment 5387252153.)What changed
packages/spec/src/data/field.zod.ts—.default('set_null')moved offdeleteBehaviorto.optional()+.meta({ description, default: 'set_null' }); a per-type check in the field-levelsuperRefinemakes an AUTHOREDset_nullon amaster_detaila named parse-time rejection (pathdeleteBehavior, message states the actual outcome — children DELETED, not kept — and names both legal re-declarations plus thelookupalternative); a post-check.overwrite()re-materializes'set_null'at its shape position. Per the 2026-08-24 ruling, the.overwrite()carries one conditional stating the general principle in code doc: never materialize a default the schema itself would refuse as authored — a baremaster_detailparses to output that OMITSdeleteBehavior(the baked value was indistinguishable from an authored one by design, so parse output rejected itself on re-parse); every other type keeps byte-identity with the.default()era. The schema stays aZodObject(shape enumerable — no pipe degradation). Accepted cost, same as the currencyprecisionprecedent: the inferredFieldoutput type declaresdeleteBehavior?(ADR-0122 forbids hand-narrowing); at runtime a parsed field carries it on every type exceptmaster_detail, where absence is the honest spelling.packages/spec/src/migrations/entries/semantic/18.field-master-detail-set-null-refused.ts(+ regeneratedregistry.ts) — the Q2 = CSemanticMigrationentry,field-scale-precision-integer-refusedprecedent, with option B's reasoning stated in thereplacementtext ("restrictis the only rewrite that cannot lose data").packages/objectql/src/engine.ts(cascadeDeleteRelationsonly; unchanged in R3) — the coercion stays (Q3 = B; engine behavior unchanged). It logs loudly when an authoredset_nullon amaster_detailreaches the coercion site — the populations parse-time rejection measurably cannot catch (raw registrations; rows/artifacts stored before the tightening). Sanctioned #9750 shape: reach forerror, fall back towarn, never an optional call. After the idempotent-materialization change, artifacts built from this version onward no longer carry the baked key on baremaster_detail, so the #4447-class residual log narrows to pre-tightening artifacts and genuinely raw registrations.Tests — spec: rejection pinned (path + message head + both remedies named); bare
master_detailparses and its output omitsdeleteBehavior(flipped from the R2 byte-identity pin, per the ruling); parse idempotency pinned on the mainline chain (FieldSchema.parseoutput re-parses green;ObjectSchema.create()output re-parses green — the exact chain that carried the defect); the shape-position byte-identity pin re-targeted tolookup(the types that still materialize); authoredcascade/restrictuntouched;set_nullonlookup(required and not) stays legal; the #4447 showcase-verbatim datetime shape stays legal; shape enumerability pinned. objectql: the #9625 pina master_detail declaring an explicit deleteBehavior:set_null still cascadesstays true and untouched (registrations are raw; the engine never re-parses); the R2 describe pinning the coercion-site log (fires once on authoredset_null, error path and warn-fallback path, NOT on bare/cascade) unchanged.Docs (declared surface addition, unchanged in R3 — two sentences the diff falsifies):
content/docs/protocol/objectql/types.mdxand themaster_detailrow ofcontent/docs/data-modeling/field-types.mdxnow state the parse-time rejection and the raw/stored residual.Changeset:
minorfor@objectstack/specand@objectstack/objectql; prose updated in R3 with the artifact honesty note (baremaster_detailbuilt artifacts stop carryingdeleteBehavior; engine behavior measured unchanged).Measurements (R3, on the branch with origin/main merged in)
turbo run build --filter=@objectstack/example-showcaseexits 2 —defineStack validation failed (4 issues)at exactlyobjects.9.fields.f_master_detail.deleteBehavior,objects.17.fields.team.deleteBehavior,objects.17.fields.project.deleteBehavior,objects.20.fields.project.deleteBehavior. Mechanism probes at the same head:FieldSchema.parse(bare md)output carriesdeleteBehavior: 'set_null'and re-parse REJECTS atdeleteBehavior;ObjectSchema.create(bare md)output re-parse REJECTS atfields.parent.deleteBehavior.md_bareoutput omits the key.master_detail× all three values, fourlookupvariants, text/datetime/number/currency/tree, the data: created_at is client-writable on an ordinary PATCH — the audit anchor can be forged silently #4447 showcase-verbatim datetime) captured against the built dist BEFORE and AFTER the conditional: 13/14 entries byte-identical (JSON.stringify, key order included;md_set_nullstays REJECTED at the same path). The single flip ismd_bare, whose only movement is the omission ofdeleteBehavior— no other key or value moved.deleteBehaviorread site in the workspace enumerated; the engine reads raw definitions (absent ≡set_null→ cascade onmaster_detail— the FieldSchema acceptsdeleteBehavior: 'set_null'on amaster_detail, and the engine silently resolves it tocascade#9689 exhaustion matrix); the one consumer that distinguishes absent — lint rulerelationship/delete-behavior— consumes RAW authored objects (verified from its tests), where absent has always meant "not authored", so it is unaffected by parse-output shape. Its message text staleness (still offeringset_nullonmaster_detail) filed as lintrelationship/delete-behaviorsuggestion still namesset_nullas declarable on a master_detail — a spelling #9689 makes a parse-time rejection #11668.packages/spec/dist/data/index.mjs(esbuild spellingif (field.type === "master_detail") return field;) before any post-fix measurement was read.check:type-check-debt --re-measurered at 355 vs the recorded 354 for@objectstack/objectql. Attributed by per-file tsc enumeration: the +1 is the R2 log-test helper's 1-argregistry.registerObject(o as any)(engine-cascade-delete.test.ts:577, TS2554 — the base file's identical pile member balances the rest out). Repaired with the gate's own author remedy — the house 2-arg spelling (batch-row-authoring-feedback.test.tsprecedent); file re-run 16/16 green, re-measure back to "none above its recorded number".Q4 = B — draft ADR entry (for the maintainer; NOT written to
docs/adr/**)Effect on #9784
This PR now lands the
master_detailslice of #9784's decision (baremaster_detailartifacts stop carrying the inert-and-refused key), which was its most urgent half. What remains for #9784 is the inert-but-accepted key on non-reference types (text/datetime/number …), where byte-identity is preserved by this PR and removal would be a real artifact migration. The relocation still makes that edit a per-type conditional in one place (the.overwrite()), and #11423 (currencyprecision) follows the second principle above directly.Deviations
claude/issue-9689-master-detail-set-null-r2(claim says…-set-null). The stale probe branch at2065e31d5is not an ancestor of currentmain(old-main history; upstream history divergence), so a plain push is non-fast-forward; branch deletion is refused by the proxy (403 on both the git and REST paths), and force-push is contract-banned for this seat even though the dispatch offered it (prompt-vs-contract conflict resolved toward the contract, as the contract instructs). The stale branch carries zero commits of its own and was left in place.dispatch-gates.mjsrun without paths (the script derives the changed set from the merge base itself — the takeover prompt's explicit changed-paths argument conflicts with the standing no-paths rule; resolved toward the standing contract, recorded in the os-dev report).field.zod.ts+ its tests + PR body/changeset prose, but the debt-ratchet red above lands inpackages/objectql/src/engine-cascade-delete.test.ts— a file of THIS PR's own R2 diff, red on a gate this patch round must drive green, with the fix being the gate's own prescribed author remedy (never a ledger raise). Declared here and in the report rather than left to a fork-back: the contract increment itself did not move.Gates
R3 gates derived from the actual diff via
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no paths passed) at bothb8c3f8507and the final head2bed67490— list identical. At the final head: full@objectstack/objectqlsuite green (231 files / 4106 tests),check:type-check-debt --re-measuregreen ("none above its recorded number"), the 41-entry light battery all exit 0 (includingcheck:dev-prereqsafter topping the closure to 67/67 built),check:generatedgreen. Spec suite (420 files / 11226 tests), showcase build 61/61, showcasetsc --noEmit, and workspace typecheck 124/124 ran atb8c3f8507; carried to2bed67490by invariance — the only file the later commit touches isengine-cascade-delete.test.ts, excluded from every build/typecheck program (packages/objectql/tsconfig.jsonexcludes**/*.test.ts) and reachable only by vitest and the debt program, both re-run green at the final head. Per-gate verdict lines in the os-dev report on #9689.needs:contract-reviewre-fires on this new diff (clause-②): this PR stays draft and is held for contract review. Out of scope, filed separately: #11409 (skill), #11410 (field designer form), #11423 (currency twin), #11668 (lint message).Generated by Claude Code